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