pwgen.py 6.29 KB
Newer Older
ale's avatar
ale committed
1 2 3 4 5 6
#!/usr/bin/env python
# pwgen.py
# Creates and provides passwords to Ansible.
#
# See also ansible sources: lib/ansible/parsing/yaml/constructor.py

7 8
from __future__ import print_function

ale's avatar
ale committed
9
import argparse
ale's avatar
ale committed
10
import base64
11
import binascii
ale's avatar
ale committed
12 13 14 15 16
import os
import random
import shutil
import subprocess
import string
ale's avatar
ale committed
17
import sys
ale's avatar
ale committed
18 19 20 21
import tempfile
import yaml


ale's avatar
ale committed
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
# Possible exit codes for this program.
EXIT_NOTHING_TO_DO = 0
EXIT_CHANGED = 1
EXIT_ERROR = 2


# Returns the absolute path to a file. If the given path is relative,
# it will be evaluated based on the given path_reference.
def _abspath(path, relative_to='/'):
    if path.startswith('/'):
        return path
    return os.path.abspath(os.path.join(os.path.dirname(relative_to), path))


# A version of yaml.safe_load that supports the special 'include'
# top-level attribute and recursively merges included files.
def _read_yaml(path):
    with open(path) as fd:
        data = yaml.safe_load(fd)
    if not isinstance(data, list):
        raise Exception('data in %s is not a list' % (path,))
    # Find elements that include other files.
    out = []
    for entry in data:
        if 'include' in entry:
            out.extend(_read_yaml(_abspath(entry['include'], path)))
        else:
            out.append(entry)
    return out


ale's avatar
ale committed
53 54 55 56 57 58 59 60 61
def decrypt(src):
    return subprocess.check_output(
        ['ansible-vault', 'decrypt', '--output=-', src])


def encrypt(data, dst):
    p = subprocess.Popen(
        ['ansible-vault', 'encrypt', '--output=' + dst, '-'],
        stdin=subprocess.PIPE)
62
    p.communicate(data.encode())
ale's avatar
ale committed
63 64 65 66 67 68 69 70 71 72 73
    rc = p.wait()
    if rc != 0:
        raise Exception('ansible-vault encrypt error')


def generate_simple_password(length=32):
    """Simple password generator.

    The resulting passwords should be alphanumeric, easily
    cut&pastable and usable on the command line.
    """
ale's avatar
ale committed
74
    n = int(length * 5 / 8)
75
    return base64.b32encode(os.urandom(n)).rstrip('='.encode())
ale's avatar
ale committed
76 77 78 79 80 81 82 83 84 85 86


def generate_binary_secret(length=32):
    """Binary password generator.

    Generates more complex passwords. Unfortunately at the moment the
    result needs to be UTF8-encodable due to Ansible limitations, so
    we can't just grab some random bytes from os.urandom() and we
    base64-encode them instead.

    """
ale's avatar
ale committed
87
    n = int(length * 3 / 4)
88
    return base64.b64encode(os.urandom(n)).rstrip('='.encode())
ale's avatar
ale committed
89 90


91 92
def generate_hex_secret(length=16):
    """Binary password generator (hex-encoded)."""
ale's avatar
ale committed
93
    return binascii.hexlify(os.urandom(int(length/2)))
94 95


blallo's avatar
blallo committed
96 97
def _dnssec_keygen():
    """Older bind versions use dnssec-keygen to generate TSIG keys.
98

blallo's avatar
blallo committed
99 100 101
    dnssec-keygen outputs the random base name it has chosen for
    its output files. We need to provide a zone name, but it
    doesn't matter what the value is.
102 103 104 105 106
    """
    tmp_dir = tempfile.mkdtemp()
    try:
        base = subprocess.check_output([
            '/usr/sbin/dnssec-keygen', '-a', 'HMAC-SHA512', '-b', '512',
107
            '-n', 'USER', '-K', tmp_dir, 'pwgen',
108
        ]).strip()
109
        base = base.decode()
110 111 112 113 114 115 116 117 118 119 120 121
        result = {'algo': 'HMAC-SHA512'}
        with open(os.path.join(tmp_dir, base + '.key')) as fd:
            result['public'] = fd.read().split()[7]
        with open(os.path.join(tmp_dir, base + '.private')) as fd:
            for line in fd.readlines():
                if line.startswith('Key: '):
                    result['private'] = line.split()[1]
        return result
    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)


blallo's avatar
blallo committed
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
def _tsig_keygen():
    """In newer versions, the utility to generate TSIG keys is not
    dnssec-keygen anymore, but tsig-keygen.
    """
    base = subprocess.check_output([
        '/usr/sbin/tsig-keygen', '-a', 'HMAC-SHA512', 'pwgen'
    ]).strip().split()
    result = {'algo': 'HMAC-SHA512'}
    result['private'] = base[6].decode()[1:-2]
    result['public'] = result['private'][-32:]
    return result


def generate_tsig_key():
    """Create TSIG keys to use with Bind version 9.

    The result is a dictionary with the attributes 'algo', 'public'
    and 'private'.

    """
    if os.path.exists("/usr/sbin/tsig-keygen"):
        result = _tsig_keygen()
    else:
        result = _dnssec_keygen()
    if not result.get('public') or not result.get('private'):
        raise Exception('Could not parse dnssec-keygen output')
    return result


151 152 153 154 155 156 157 158
def generate_rsa_key(bits):
    """Create a RSA private key of the specified size.

    The result is a PEM-encoded string.
    """
    return subprocess.check_output(['openssl', 'genrsa', str(bits)])


ale's avatar
ale committed
159 160 161
def generate_password(entry):
    ptype = entry.get('type', 'simple')
    if ptype == 'simple':
ale's avatar
ale committed
162
        return generate_simple_password(length=int(entry.get('length', 32)))
ale's avatar
ale committed
163
    elif ptype == 'binary':
ale's avatar
ale committed
164
        return generate_binary_secret(length=int(entry.get('length', 32)))
165 166
    elif ptype == 'hex':
        return generate_hex_secret(length=int(entry.get('length', 32)))
167 168
    elif ptype == 'tsig':
        return generate_tsig_key()
169 170
    elif ptype == 'rsakey':
        return generate_rsa_key(bits=int(entry.get('bits', 2048)))
ale's avatar
ale committed
171 172 173 174 175
    else:
        raise Exception('Unknown password type "%s"' % ptype)


def main():
ale's avatar
ale committed
176 177 178 179 180 181 182 183 184 185 186 187 188
    parser = argparse.ArgumentParser(description='''
Autogenerate secrets for use with Ansible.

Secrets are encrypted with Ansible Vault, so the
ANSIBLE_VAULT_PASSWORD_FILE environment variable must be defined.
''')
    parser.add_argument(
        '--vars', metavar='FILE', dest='vars_file',
        help='Output vars file')
    parser.add_argument(
        'password_file',
        help='Secrets metadata')
    args = parser.parse_args()
ale's avatar
ale committed
189 190

    if not os.getenv('ANSIBLE_VAULT_PASSWORD_FILE'):
ale's avatar
ale committed
191
        raise Exception("You need to set ANSIBLE_VAULT_PASSWORD_FILE")
ale's avatar
ale committed
192 193 194

    passwords = {}

ale's avatar
ale committed
195 196
    if os.path.exists(args.vars_file):
        passwords.update(yaml.safe_load(decrypt(args.vars_file)))
ale's avatar
ale committed
197 198 199

    changed = False

ale's avatar
ale committed
200 201 202 203 204 205
    for entry in _read_yaml(args.password_file):
        name = entry['name']
        if name not in passwords:
            print("Generating password for '%s'" % name, file=sys.stderr)
            passwords[name] = generate_password(entry)
            changed = True
ale's avatar
ale committed
206 207

    if changed:
ale's avatar
ale committed
208 209 210 211
        encrypt(yaml.dump(passwords), args.vars_file)
        return EXIT_CHANGED

    return EXIT_NOTHING_TO_DO
ale's avatar
ale committed
212 213 214


if __name__ == '__main__':
ale's avatar
ale committed
215 216 217 218 219 220
    try:
        sys.exit(main())
    except Exception as e:
        print("Error: %s" % str(e), file=sys.stderr)
        sys.exit(EXIT_ERROR)