#!/usr/bin/env python
# -*- coding: utf8 -*-

description = """
Скрипт для работы с секретами

В пустом каталоге:
direct-vault init
или 
direct-vault import /some/path

В инициализированном каталоге:
direct-vault ls 
direct-vault touch secret-1
echo 'some-text' | direct-vault tee secret-3
direct-vault cat secret-3
direct-vault mkdir ssl-certs
cat direct.crt | direct-vault tee ssl-certs/direct.crt
direct-vault touch ssl-certs/asdf
direct-vault rm ssl-certs/asdf
direct-vault mkdir ssl-crtsdf
direct-vault rmdir ssl-crtsdf
direct-vault export /some/path

Пароль для шифрования архива: 
  * можно передавать при запуске через файл, переменную окружения или параметром; см. --pass.
  * можно записать в ~/.direct-vault/<имя каталога с архивом>, т.е. для архива /secrets/direct.prod -- файл ~/.direct-vault/direct.prod
"""

TODO = """
 - тесты
 - owner файлов в архиве
 - ~/.direct-vault
 + брать каталог из параметров
 + rm
 + rmdir
 + справку
 + уметь брать пароль из ком. строки; из файла
 - (?) уметь брать пароль из файла, к-рый указан в переменной окружения
 + архивировать с паролем
 + в ls показывать пустые каталоги (может, в meta тоже)
 + import (как init, но содержимое взять из каталога)
"""

import os
import re
import sys

import argparse
import copy
#import getpass
import fnmatch
import hashlib
import json
import socket
import subprocess
import time
import tempfile
import shutil
from collections import defaultdict

def init_cmds():
    global cmds
    cmds = {
            'init': {
                'code': cmd_init,
                'need_unarch': False,
                'need_arch': True,
                'desc': 'создать пустой архив',
                },
            'repack': {
                'code': cmd_repack,
                'need_unarch': True,
                'need_arch': True,
                'desc': 'распаковать и запаковать; полезно для перегенерации metadata',
                },
            'ls': {
                'code': cmd_ls,
                'need_unarch': True,
                'need_arch': False,
                'desc': 'показать список файлов в архиве',
                },
            'cat': {
                'code': cmd_cat,
                'need_unarch': True,
                'need_arch': False,
                'desc': 'вывести содержимое указанного файла (файлов) из архива',
                },
            'touch': {
                'code': cmd_touch,
                'need_unarch': True,
                'need_arch': True,
                'desc': 'создать (пустой) файл в архиве',
                },
            'rm': {
                'code': cmd_rm,
                'need_unarch': True,
                'need_arch': True,
                'desc': 'удалить файл/файлы',
                },
            'mkdir': {
                'code': cmd_mkdir,
                'need_unarch': True,
                'need_arch': True,
                'desc': 'создать каталог',
                },
            'rmdir': {
                'code': cmd_rmdir,
                'need_unarch': True,
                'need_arch': True,
                'desc': 'удалить каталог (пустой)',
                },
            'tee': {
                'code': cmd_tee,
                'need_unarch': True,
                'need_arch': True,
                'desc': 'прочитать stdin и записать в указанный файл в архиве',
                },
            'export': {
                'code': cmd_export,
                'need_unarch': True,
                'need_arch': False,
                'desc': 'вытащить все файлы из архива и поместить в указанный каталог',
                },
            'import': {
                'code': cmd_import,
                'need_unarch': False,
                'need_arch': True,
                'desc': 'создать архив из данных из указанного каталога',
                },
            }


#######
# Архивация-разархивация, если менять -- то согласованно друг с другом и предусматривать миграцию для старых архивов
def unarch_secrets(file_arch, path_to_extract, tmp_dir, passwd):
    """
    получает архив file_arch, вынимает из него файлы в каталог path_to_extract
    Реализацию надо менять синхронно с arch_secrets
    """
    tmp_arc_file = "%s/secrets.tar.gz" % tmp_dir
    # -md md5 появился из-за того, что на билдагентах фронта новая Убунту, а в новом openssl поменялся дефолтный хеш
    # https://unix.stackexchange.com/questions/344150/why-can-one-box-decrypt-a-file-with-openssl-but-another-one-cant
    to_run_openssl = ['openssl', 'enc', '-md', 'md5', '-d', '-des3', '-in', file_arch, '-out', tmp_arc_file,]
    if passwd:
        to_run_openssl += [ '-pass', passwd ]
    my_system(to_run_openssl)
    my_system(['tar', '-xzf', tmp_arc_file, '-C', path_to_extract])
    return 0

def arch_secrets(file_arch, path_to_arch, tmp_dir, passwd):
    """
    получает каталог path_to_arch, делает архив file_arch
    Реализацию менять синхронно с unarch_secrets
    tmp_dir -- временный каталог, куда можно складывать промежуточные файлы
    """
    tmp_arc_file = "%s/secrets.tar.gz" % tmp_dir
    my_system(['tar', '-czf', tmp_arc_file, '-C', path_to_arch, '.'])
    to_run_openssl = ['openssl', 'enc', '-des3', '-in', tmp_arc_file, '-out', file_arch,]
    if passwd:
        to_run_openssl += [ '-pass', passwd ]
    my_system(to_run_openssl)
    return 0
#######


#######
# Вспомогательные ф-ции
def die(message=''):
    sys.stderr.write("%s\n" % message)
    exit(1)

def my_system(cmd, verbose=False):
    if verbose:
        print("going to exec: %s\n" % cmd)
    exit_code = subprocess.call(cmd)
    if exit_code != 0:
        die("cmd failed, stop (%s)" % cmd)
    return
#######


#######
# Обработка команд
def cmd_init(tmp_dir, extra):
    # ничего: каталог подготовлен заранее, упакуется в общем цикле
    return 0

def cmd_repack(tmp_dir, extra):
    # ничего: распаковки и упаковка делаются в общем цикле
    return 0

def cmd_ls(tmp_dir, extra):
    files = make_meta("%s/secrets" % tmp_dir)
    for f in files:
        if f['type'] == 'empty dir':
            print "%s empty dir" % (f['name'])
        else:
            print "%s %s %s" % (f['name'], f['size'], f['md5'])
    return 0

def cmd_cat(tmp_dir, extra):
    for f in extra:
        print open("%s/secrets/%s" % (tmp_dir, f)).read(), 
    return 0

def cmd_rm(tmp_dir, files):
    for f in files:
        os.remove('%s/secrets/%s' % (tmp_dir, f))
    return 0

def cmd_touch(tmp_dir, files):
    for f in files:
        open("%s/secrets/%s" % (tmp_dir, f), "w")
    return 0

def cmd_mkdir(tmp_dir, extra):
    for d in extra:
        os.mkdir('%s/secrets/%s' % (tmp_dir, d))
    return 0

def cmd_rmdir(tmp_dir, extra):
    for d in extra:
        os.rmdir('%s/secrets/%s' % (tmp_dir, d))
    return 0

def cmd_tee(tmp_dir, extra):
    f = extra[0]
    text = sys.stdin.read()
    with open("%s/secrets/%s" % (tmp_dir, f), "w") as f:
        f.write(text)
    return 0

def cmd_export(tmp_dir, extra):
    dst_dir = extra[0]
    my_system(['cp', '-r', '%s/secrets' % tmp_dir, dst_dir])
    return 0

def cmd_import(tmp_dir, extra):
    src_dir = extra[0]
    my_system(['cp', '-r', '%s/.' % src_dir, '%s/secrets/' % tmp_dir])
    return 0
######

######
# работа с метаданными и общий цикл 
def make_meta(path):
    """
    возвращает хеш/массив метаданнх про секреты в директории path
    """
    cwd = os.getcwd()
    os.chdir(path)
    
    files = []
    empty_dirs = []
    for root, dirnames, filenames in os.walk('.'):
        if not dirnames and not filenames:
            empty_dirs.append(root)
        for filename in fnmatch.filter(filenames, '*'):
            files.append(os.path.join(root, filename))

    metadata = []
    for f in empty_dirs:
        h = {}
        h['type'] = 'empty dir'
        h['name'] = re.sub(r'^\./', '', f, count=1)
        metadata.append(h)

    for f in files:
        h = {}
        h['type'] = 'file'
        h['size'] = os.path.getsize(f)
        h['name'] = re.sub(r'^\./', '', f, count=1)

        filehash = hashlib.md5()
        filehash.update(open(f).read())
        h['md5'] = filehash.hexdigest()
        metadata.append(h)

    os.chdir(cwd)

    metadata.sort(key=lambda s: s['name'])
    
    return metadata


def write_meta(data):
    with open("meta.json", 'w') as fh:
        json.dump(data, fh, indent=4, sort_keys=True)
    return


def do_one_cmd(opts):
    # готовим каталоги, разархивируем секреты
    if opts.dir:
        os.chdir(opts.dir)

    dir_secrets = os.getcwd()

    dir_tmp = tempfile.mkdtemp()
    os.mkdir('%s/secrets' % dir_tmp)

    if cmds[opts.cmd]['need_unarch']:
        unarch_secrets('secrets.tar.gz.enc', "%s/secrets" % dir_tmp, dir_tmp, opts.passwd)

    # выполняем запрошенное действие
    cmds[opts.cmd]['code'](dir_tmp, opts.extra)

    # заворачиваем секреты обратно в архив, удаляем временный каталог
    if cmds[opts.cmd]['need_arch']:
        metadata = make_meta("%s/secrets" % dir_tmp)
        write_meta(metadata)
        arch_secrets('secrets.tar.gz.enc', "%s/secrets" % dir_tmp, dir_tmp, opts.passwd)

    shutil.rmtree(dir_tmp)
    return 0


def parse_options():
    my_name = os.path.basename( sys.argv[0] )
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument("-d", dest="dir", help="каталог с секретным архивом; по умолчанию -- текущий", type=str)
    parser.add_argument("-p", "--pass", dest="passwd", help="Как получить пароль для архива. Можно: pass:<пароль>, env:<переменная окружения с паролем>, file:<файл с паролем> (как в openssl). Если ничего не указать -- попросит ввести.", type=str)
    parser.add_argument("-h", "--help", dest="help", help="Справка", action="store_true")
    opts, extra = parser.parse_known_args()

    if opts.help:
        print description
        print parser.format_help()
        print "commands:"
        for cmd in sorted(cmds.keys()):
            print "%s:\n  %s" % (cmd, cmds[cmd]['desc'])
        exit(0)

    if len(extra) <= 0:
        die("expecting action")

    if not opts.dir:
        opts.dir = os.getcwd()

    if opts.passwd and not re.match(r'^(env|file|pass):.*$', opts.passwd):
        die("can't parse pass '%s' (expecting '(env|file|pass):.*)'" % opts.passwd)

    # Если пароль не задан в параметрах, ищем файл в ~/.direct-vault
    if not opts.passwd:
        archive_name = os.path.basename(os.path.expanduser(opts.dir.rstrip('/')))
        home = os.path.expanduser("~")
        pass_file = "%s/.direct-vault/%s" % (home, archive_name)
        if os.path.isfile(pass_file): 
            opts.passwd = "file:%s" % pass_file 

    opts.cmd = extra.pop(0)
    
    if not opts.cmd in cmds:
        die("unknown action '%s'" % opts.cmd)

    opts.extra = extra

    return opts

def run():
    init_cmds()
    opts = parse_options()
    
    do_one_cmd(opts)
    exit(0)


if __name__ == '__main__':
    run();
