1# -*- coding: utf-8 -*- 2# (c) 2017, Patrick Deelman <patrick@patrickdeelman.nl> 3# (c) 2017 Ansible Project 4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5from __future__ import (absolute_import, division, print_function) 6__metaclass__ = type 7 8 9DOCUMENTATION = ''' 10 name: passwordstore 11 author: 12 - Patrick Deelman (!UNKNOWN) <patrick@patrickdeelman.nl> 13 short_description: manage passwords with passwordstore.org's pass utility 14 description: 15 - Enables Ansible to retrieve, create or update passwords from the passwordstore.org pass utility. 16 It also retrieves YAML style keys stored as multilines in the passwordfile. 17 options: 18 _terms: 19 description: query key. 20 required: True 21 passwordstore: 22 description: location of the password store. 23 default: '~/.password-store' 24 directory: 25 description: The directory of the password store. 26 env: 27 - name: PASSWORD_STORE_DIR 28 create: 29 description: Create the password if it does not already exist. Takes precedence over C(missing). 30 type: bool 31 default: false 32 overwrite: 33 description: Overwrite the password if it does already exist. 34 type: bool 35 default: 'no' 36 umask: 37 description: 38 - Sets the umask for the created .gpg files. The first octed must be greater than 3 (user readable). 39 - Note pass' default value is C('077'). 40 env: 41 - name: PASSWORD_STORE_UMASK 42 version_added: 1.3.0 43 returnall: 44 description: Return all the content of the password, not only the first line. 45 type: bool 46 default: 'no' 47 subkey: 48 description: Return a specific subkey of the password. When set to C(password), always returns the first line. 49 default: password 50 userpass: 51 description: Specify a password to save, instead of a generated one. 52 length: 53 description: The length of the generated password. 54 type: integer 55 default: 16 56 backup: 57 description: Used with C(overwrite=yes). Backup the previous password in a subkey. 58 type: bool 59 default: 'no' 60 nosymbols: 61 description: use alphanumeric characters. 62 type: bool 63 default: 'no' 64 missing: 65 description: 66 - List of preference about what to do if the password file is missing. 67 - If I(create=true), the value for this option is ignored and assumed to be C(create). 68 - If set to C(error), the lookup will error out if the passname does not exist. 69 - If set to C(create), the passname will be created with the provided length I(length) if it does not exist. 70 - If set to C(empty) or C(warn), will return a C(none) in case the passname does not exist. 71 When using C(lookup) and not C(query), this will be translated to an empty string. 72 version_added: 3.1.0 73 type: str 74 default: error 75 choices: 76 - error 77 - warn 78 - empty 79 - create 80''' 81EXAMPLES = """ 82# Debug is used for examples, BAD IDEA to show passwords on screen 83- name: Basic lookup. Fails if example/test doesn't exist 84 ansible.builtin.debug: 85 msg: "{{ lookup('community.general.passwordstore', 'example/test')}}" 86 87- name: Basic lookup. Warns if example/test does not exist and returns empty string 88 ansible.builtin.debug: 89 msg: "{{ lookup('community.general.passwordstore', 'example/test missing=warn')}}" 90 91- name: Create pass with random 16 character password. If password exists just give the password 92 ansible.builtin.debug: 93 var: mypassword 94 vars: 95 mypassword: "{{ lookup('community.general.passwordstore', 'example/test create=true')}}" 96 97- name: Create pass with random 16 character password. If password exists just give the password 98 ansible.builtin.debug: 99 var: mypassword 100 vars: 101 mypassword: "{{ lookup('community.general.passwordstore', 'example/test missing=create')}}" 102 103- name: Prints 'abc' if example/test does not exist, just give the password otherwise 104 ansible.builtin.debug: 105 var: mypassword 106 vars: 107 mypassword: "{{ lookup('community.general.passwordstore', 'example/test missing=empty') | default('abc', true) }}" 108 109- name: Different size password 110 ansible.builtin.debug: 111 msg: "{{ lookup('community.general.passwordstore', 'example/test create=true length=42')}}" 112 113- name: Create password and overwrite the password if it exists. As a bonus, this module includes the old password inside the pass file 114 ansible.builtin.debug: 115 msg: "{{ lookup('community.general.passwordstore', 'example/test create=true overwrite=true')}}" 116 117- name: Create an alphanumeric password 118 ansible.builtin.debug: 119 msg: "{{ lookup('community.general.passwordstore', 'example/test create=true nosymbols=true') }}" 120 121- name: Return the value for user in the KV pair user, username 122 ansible.builtin.debug: 123 msg: "{{ lookup('community.general.passwordstore', 'example/test subkey=user')}}" 124 125- name: Return the entire password file content 126 ansible.builtin.set_fact: 127 passfilecontent: "{{ lookup('community.general.passwordstore', 'example/test returnall=true')}}" 128""" 129 130RETURN = """ 131_raw: 132 description: 133 - a password 134 type: list 135 elements: str 136""" 137 138import os 139import subprocess 140import time 141import yaml 142 143 144from distutils import util 145from ansible.errors import AnsibleError, AnsibleAssertionError 146from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text 147from ansible.utils.display import Display 148from ansible.utils.encrypt import random_password 149from ansible.plugins.lookup import LookupBase 150from ansible import constants as C 151 152display = Display() 153 154 155# backhacked check_output with input for python 2.7 156# http://stackoverflow.com/questions/10103551/passing-data-to-subprocess-check-output 157def check_output2(*popenargs, **kwargs): 158 if 'stdout' in kwargs: 159 raise ValueError('stdout argument not allowed, it will be overridden.') 160 if 'stderr' in kwargs: 161 raise ValueError('stderr argument not allowed, it will be overridden.') 162 if 'input' in kwargs: 163 if 'stdin' in kwargs: 164 raise ValueError('stdin and input arguments may not both be used.') 165 b_inputdata = to_bytes(kwargs['input'], errors='surrogate_or_strict') 166 del kwargs['input'] 167 kwargs['stdin'] = subprocess.PIPE 168 else: 169 b_inputdata = None 170 process = subprocess.Popen(*popenargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) 171 try: 172 b_out, b_err = process.communicate(b_inputdata) 173 except Exception: 174 process.kill() 175 process.wait() 176 raise 177 retcode = process.poll() 178 if retcode != 0 or \ 179 b'encryption failed: Unusable public key' in b_out or \ 180 b'encryption failed: Unusable public key' in b_err: 181 cmd = kwargs.get("args") 182 if cmd is None: 183 cmd = popenargs[0] 184 raise subprocess.CalledProcessError( 185 retcode, 186 cmd, 187 to_native(b_out + b_err, errors='surrogate_or_strict') 188 ) 189 return b_out 190 191 192class LookupModule(LookupBase): 193 def parse_params(self, term): 194 # I went with the "traditional" param followed with space separated KV pairs. 195 # Waiting for final implementation of lookup parameter parsing. 196 # See: https://github.com/ansible/ansible/issues/12255 197 params = term.split() 198 if len(params) > 0: 199 # the first param is the pass-name 200 self.passname = params[0] 201 # next parse the optional parameters in keyvalue pairs 202 try: 203 for param in params[1:]: 204 name, value = param.split('=', 1) 205 if name not in self.paramvals: 206 raise AnsibleAssertionError('%s not in paramvals' % name) 207 self.paramvals[name] = value 208 except (ValueError, AssertionError) as e: 209 raise AnsibleError(e) 210 # check and convert values 211 try: 212 for key in ['create', 'returnall', 'overwrite', 'backup', 'nosymbols']: 213 if not isinstance(self.paramvals[key], bool): 214 self.paramvals[key] = util.strtobool(self.paramvals[key]) 215 except (ValueError, AssertionError) as e: 216 raise AnsibleError(e) 217 if self.paramvals['missing'] not in ['error', 'warn', 'create', 'empty']: 218 raise AnsibleError("{0} is not a valid option for missing".format(self.paramvals['missing'])) 219 if not isinstance(self.paramvals['length'], int): 220 if self.paramvals['length'].isdigit(): 221 self.paramvals['length'] = int(self.paramvals['length']) 222 else: 223 raise AnsibleError("{0} is not a correct value for length".format(self.paramvals['length'])) 224 225 if self.paramvals['create']: 226 self.paramvals['missing'] = 'create' 227 228 # Collect pass environment variables from the plugin's parameters. 229 self.env = os.environ.copy() 230 231 # Set PASSWORD_STORE_DIR if directory is set 232 if self.paramvals['directory']: 233 if os.path.isdir(self.paramvals['directory']): 234 self.env['PASSWORD_STORE_DIR'] = self.paramvals['directory'] 235 else: 236 raise AnsibleError('Passwordstore directory \'{0}\' does not exist'.format(self.paramvals['directory'])) 237 238 # Set PASSWORD_STORE_UMASK if umask is set 239 if 'umask' in self.paramvals: 240 if len(self.paramvals['umask']) != 3: 241 raise AnsibleError('Passwordstore umask must have a length of 3.') 242 elif int(self.paramvals['umask'][0]) > 3: 243 raise AnsibleError('Passwordstore umask not allowed (password not user readable).') 244 else: 245 self.env['PASSWORD_STORE_UMASK'] = self.paramvals['umask'] 246 247 def check_pass(self): 248 try: 249 self.passoutput = to_text( 250 check_output2(["pass", "show", self.passname], env=self.env), 251 errors='surrogate_or_strict' 252 ).splitlines() 253 self.password = self.passoutput[0] 254 self.passdict = {} 255 try: 256 values = yaml.safe_load('\n'.join(self.passoutput[1:])) 257 for key, item in values.items(): 258 self.passdict[key] = item 259 except (yaml.YAMLError, AttributeError): 260 for line in self.passoutput[1:]: 261 if ':' in line: 262 name, value = line.split(':', 1) 263 self.passdict[name.strip()] = value.strip() 264 except (subprocess.CalledProcessError) as e: 265 if e.returncode != 0 and 'not in the password store' in e.output: 266 # if pass returns 1 and return string contains 'is not in the password store.' 267 # We need to determine if this is valid or Error. 268 if self.paramvals['missing'] == 'error': 269 raise AnsibleError('passwordstore: passname {0} not found and missing=error is set'.format(self.passname)) 270 else: 271 if self.paramvals['missing'] == 'warn': 272 display.warning('passwordstore: passname {0} not found'.format(self.passname)) 273 return False 274 else: 275 raise AnsibleError(e) 276 return True 277 278 def get_newpass(self): 279 if self.paramvals['nosymbols']: 280 chars = C.DEFAULT_PASSWORD_CHARS[:62] 281 else: 282 chars = C.DEFAULT_PASSWORD_CHARS 283 284 if self.paramvals['userpass']: 285 newpass = self.paramvals['userpass'] 286 else: 287 newpass = random_password(length=self.paramvals['length'], chars=chars) 288 return newpass 289 290 def update_password(self): 291 # generate new password, insert old lines from current result and return new password 292 newpass = self.get_newpass() 293 datetime = time.strftime("%d/%m/%Y %H:%M:%S") 294 msg = newpass + '\n' 295 if self.passoutput[1:]: 296 msg += '\n'.join(self.passoutput[1:]) + '\n' 297 if self.paramvals['backup']: 298 msg += "lookup_pass: old password was {0} (Updated on {1})\n".format(self.password, datetime) 299 try: 300 check_output2(['pass', 'insert', '-f', '-m', self.passname], input=msg, env=self.env) 301 except (subprocess.CalledProcessError) as e: 302 raise AnsibleError(e) 303 return newpass 304 305 def generate_password(self): 306 # generate new file and insert lookup_pass: Generated by Ansible on {date} 307 # use pwgen to generate the password and insert values with pass -m 308 newpass = self.get_newpass() 309 datetime = time.strftime("%d/%m/%Y %H:%M:%S") 310 msg = newpass + '\n' + "lookup_pass: First generated by ansible on {0}\n".format(datetime) 311 try: 312 check_output2(['pass', 'insert', '-f', '-m', self.passname], input=msg, env=self.env) 313 except (subprocess.CalledProcessError) as e: 314 raise AnsibleError(e) 315 return newpass 316 317 def get_passresult(self): 318 if self.paramvals['returnall']: 319 return os.linesep.join(self.passoutput) 320 if self.paramvals['subkey'] == 'password': 321 return self.password 322 else: 323 if self.paramvals['subkey'] in self.passdict: 324 return self.passdict[self.paramvals['subkey']] 325 else: 326 return None 327 328 def run(self, terms, variables, **kwargs): 329 result = [] 330 self.paramvals = { 331 'subkey': 'password', 332 'directory': variables.get('passwordstore'), 333 'create': False, 334 'returnall': False, 335 'overwrite': False, 336 'nosymbols': False, 337 'userpass': '', 338 'length': 16, 339 'backup': False, 340 'missing': 'error', 341 } 342 343 for term in terms: 344 self.parse_params(term) # parse the input into paramvals 345 if self.check_pass(): # password exists 346 if self.paramvals['overwrite'] and self.paramvals['subkey'] == 'password': 347 result.append(self.update_password()) 348 else: 349 result.append(self.get_passresult()) 350 else: # password does not exist 351 if self.paramvals['missing'] == 'create': 352 result.append(self.generate_password()) 353 else: 354 result.append(None) 355 356 return result 357