#!/usr/bin/python3

"""
COMMAND LINE INTERFACE

$0 init
    make the current directory, which *must* be empty, an easy-openvpn
    managed openvpn config. complete with root-ca, int-ca and example
    configs.
    TODO: provide option to use existing cert-authority (but still create
    intermediate authority and request signing by cert-authority).

$0 client-genall --name <fqdn>
$0 server-genall --name <fqdn>

$0 client-makebundle --name <fqdn> --output <file.tar.gz>
    without "--output", the bundles are placed into the clients/ directory.


DIRECTORY STRUCTURE

given that CWD is the current working directory the command is executed
from:

README
    file giving some hints that this directorie's content is somewhat
    managed by easy-openvpn

certification-authority/
    the root certification authority. certifies the intermediate authority.
    Note that the private key is not necessary after the intermediate
    authority has been created.

certification-authority/crt.pem
    the certification-authority's root certificate. must be present in order
    for openvpn to verify connecting clients.

intermediate-authority/
    the intermediate authority certified by the root certification authority.
    all servers and clients are certified by this authority.

intermediate-authority/crt.pem
    the intermediate-authority's certificate signed by the root certification
    authority. must be present in order for openvpn to verify connecting

intermediate-authority/key.pem
    the intermediate-authority's private key. if present, easy-openvpn
    can sign/generate new client certificates on this host.

clients/
    client certificates.

clients/«fqdn»/crt.pem
clients/«fqdn»/key.pem
clients/«fqdn»/crt+chain.pem
    the public certificate and private key of client with the name «fqdn».
    crt.pem is the public certificate, key.pem is the private key, and
    crt+chain.pem is the public certificate with the certification chain
    concatenated together.

servers/
    server certificates.

servers/«fqdn»/crt.pem
servers/«fqdn»/key.pem
servers/«fqdn»/crt+chain.pem
    the public certificate and private key of server with the name «fqdn».
    crt.pem is the public certificate, key.pem is the private key, and
    crt+chain.pem is the public certificate with the certification chain
    concatenated together.


"""

import argparse
import binascii
import logging
import os
import shutil
import socket
import stat
import subprocess
import sys
import tarfile
import tempfile


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)


DIR_ROOTCA = "certification-authority"
DIR_INTCA = "intermediate-authority"
DIR_CLIENTS = "clients"
DIR_SERVERS = "servers"
DIR_CLIENTSKEL = "client.skel"
DIR_CLIENTCONFIG = "client-config.d"
DIR_CRL = "crl.d"


def usage():
    print("Usage: {} COMMAND [--help|OPTIONS]"
        .format(sys.argv[0]), file=sys.stderr)
    print("")
    print("AVAILABLE COMMANDS")
    print("")
    print("  init ... initialize current workdir")
    print("")
    print("  client-genall --name <fqdn>")
    print("  server-genall --name <fqdn>")
    print("")
    print("  client-makebundle --name <fqdn> [--output <file.tar.gz>]")
    print("")


def gendhparams(workdir):
    """
    generate the dh params file
    """
    subprocess.run([
        "openssl", "dhparam",
        "-out", os.path.join(workdir, "dh2048.pem"),
        "2048"
    ], check=True)

def gencert(workdir, category, name):
    """
    workdir ... path to the easy-openvpn directory
    category ... 'client' or 'server'
    name ... the new client's name
    :raises: exception
    """
    if category == 'client':
        categorydirname = DIR_CLIENTS
        extendedKeyUsage = 'clientAuth'
    elif category == 'server':
        categorydirname = DIR_SERVERS
        extendedKeyUsage = 'serverAuth'
    else:
        raise Exception("unknown category: {}".format(category))
    dirname = name

    intcadirpath = os.path.join(workdir, DIR_INTCA)
    intcakeyfilepath = os.path.join(intcadirpath, "key.pem")
    intcacrtfilepath = os.path.join(intcadirpath, "crt.pem")
    intcaconfigfilepath = os.path.join(intcadirpath, "openssl.cnf")

    dirpath = os.path.join(workdir, categorydirname, dirname)
    keyfilepath = os.path.join(dirpath,"key.pem")
    keybits = 4096
    csrfilepath = os.path.join(dirpath,"csr.pem")
    crtfilepath = os.path.join(dirpath,"crt.pem")
    chainfilepath = os.path.join(dirpath,"crt+chain.pem")

    tempfilepath = tempfile.NamedTemporaryFile()

    # create client directory if not exists
    if not os.path.exists(dirpath):
        os.mkdir(dirpath)
    if not os.path.isdir(dirpath):
        raise Exception("corrupted easy-openvpn; is not a directory: {}"
            .format(dirpath))

    # generate private key if not exists
    if not os.path.exists(keyfilepath):
        subprocess.run(
            ["openssl", "genrsa", "-out", keyfilepath, str(keybits)],
            check=True)
        os.chmod(keyfilepath, 0o400)

    tempfilepath.truncate(0)
    tempfilepath.writelines([
        b"[req]\n",
        b"string_mask = utf8only\n",
        b"distinguished_name = req_distinguished_name\n",
        b"[req_distinguished_name]\n",
    ])
    tempfilepath.flush()

    # generate the certificate signing request if not exists
    if not os.path.exists(csrfilepath):
        subprocess.run(
            ["openssl", "req", "-new", "-config", tempfilepath.name,
                "-subj", "/CN="+name,
                "-batch", "-sha256", "-key", keyfilepath, "-out", csrfilepath],
            check=True)
        os.chmod(csrfilepath, 0o440)

    tempfilepath.truncate(0)
    tempfilepath.writelines([
        b"subjectKeyIdentifier = hash\n",
        b"authorityKeyIdentifier = keyid:always, issuer\n",
        b"basicConstraints = critical, CA:false\n",
        b"keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment, keyAgreement\n",
        b"extendedKeyUsage = ", extendedKeyUsage.encode(), b"\n",
    ])
    tempfilepath.flush()

    # sign the csr if crt not yet exists
    if not os.path.exists(crtfilepath):
        proc = subprocess.run(
            ["openssl", "x509", "-req", "-in", csrfilepath,
                "-CA", intcacrtfilepath, "-CAkey", intcakeyfilepath,
                "-extfile", tempfilepath.name, "-sha256", "-days", "3650",
                "-set_serial", "0x01"+os.urandom(16).hex(),
                "-text",],
            check=True, stdout=subprocess.PIPE)
        crtfile = open(crtfilepath, "xb")
        crtfile.write(proc.stdout)
        crtfile.close()
        os.chmod(crtfilepath, 0o440)

    # concat crt and chain in correct order
    if not os.path.exists(chainfilepath):
        proc = subprocess.run(["cat", crtfilepath, intcacrtfilepath],
            check=True, stdout=subprocess.PIPE)
        chainfile = open(chainfilepath, "xb")
        chainfile.write(proc.stdout)
        intcacrtfile = open(intcacrtfilepath, "rb")
        chainfile.write(intcacrtfile.read())
        intcacrtfile.close()
        chainfile.close()
        os.chmod(chainfilepath, 0o440)

    return os.EX_OK

def genrootca(rootcadir):
    """
    rootcadir ... path to the directory (must already exist and be empty)
        into which a certificate authority should be created
    :raises: exception
    """
    # make key.pem
    subprocess.run(
        ["openssl", "genpkey",
            "-algorithm", "rsa",
            "-pkeyopt", "rsa_keygen_bits:4096",
            "-out", os.path.join(rootcadir, "key.pem"),
        ],
        check=True)

    # make crt.pem
    tempfilepath = tempfile.NamedTemporaryFile()
    tempfilepath.truncate(0)
    tempfilepath.writelines([
        b"[req]\n",
        b"string_mask = utf8only\n",
        b"distinguished_name = req_distinguished_name\n",
        b"x509_extensions = v3_ca\n",
        b"[req_distinguished_name]\n",
        b"[v3_ca]\n",
        b"subjectKeyIdentifier = hash\n",
        b"authorityKeyIdentifier = keyid:always, issuer\n",
        b"basicConstraints = critical, CA:true, pathlen:1\n",
        b"keyUsage = critical, keyCertSign\n",
    ])
    tempfilepath.flush()

    subprocess.run(
        ["openssl", "req", "-new", "-x509",
            "-config", tempfilepath.name,
            "-batch",
            "-subj", "/CN=OpenVPN Certification Authority",
            "-set_serial", "0x01"+os.urandom(16).hex(),
            "-days", "3650",
            "-out", os.path.join(rootcadir, "crt.pem"),
            "-key", os.path.join(rootcadir, "key.pem"),
            "-text",
            "-sha256",
            "-utf8",
        ],
        check=True)

    pass

def genintca(rootcadir, intcadir):
    """
    workdir ... path to the easy-openvpn directory
    name ... the new client's name
    category ... 'client' or 'server'
    :raises: exception
    """
    # gen private key of intca
    subprocess.run(
        ["openssl", "genpkey",
            "-algorithm", "rsa",
            "-pkeyopt", "rsa_keygen_bits:4096",
            "-out", os.path.join(intcadir, "key.pem"),
        ],
        check=True)

    # gen csr of intca
    tempfilepath = tempfile.NamedTemporaryFile()
    tempfilepath.truncate(0);
    tempfilepath.writelines([
        b"[req]\n",
        b"string_mask = utf8only\n",
        b"distinguished_name = req_distinguished_name\n",
        b"[req_distinguished_name]\n",
    ])
    tempfilepath.flush()

    subprocess.run(
        ["openssl", "req", "-new",
            "-config", tempfilepath.name,
            "-batch",
            "-subj", "/CN=OpenVPN Intermediate Authority",
            "-set_serial", "0x01"+os.urandom(16).hex(),
            "-out", os.path.join(intcadir, "csr.pem"),
            "-key", os.path.join(intcadir, "key.pem"),
            "-text",
            "-sha256",
            "-utf8",
        ],
        check=True)

    # gen crt of intca
    tempfilepath = tempfile.NamedTemporaryFile()
    tempfilepath.truncate(0);
    tempfilepath.writelines([
        b"subjectKeyIdentifier = hash\n",
        b"authorityKeyIdentifier = keyid:always, issuer\n",
        b"basicConstraints = critical, CA:true, pathlen:0\n",
        b"keyUsage = critical, keyCertSign\n",
    ])
    tempfilepath.flush()

    subprocess.run(
        ["openssl", "x509",
            "-req", "-in", os.path.join(intcadir, "csr.pem"),
            "-CA", os.path.join(rootcadir, "crt.pem"),
            "-CAkey", os.path.join(rootcadir, "key.pem"),
            "-extfile", tempfilepath.name,
            "-sha256",
            "-days", "3650",
            "-set_serial", "0x01"+os.urandom(16).hex(),
            "-text",
            "-out", os.path.join(intcadir, "crt.pem"),
        ],
        check=True, stdout=subprocess.PIPE)


def makedefaultclientconfig(targetdir):
    """
    write client's config particular to easy-openvpn into the specified
    directory
    """
    hostname = socket.getfqdn().encode()
    cwddir = os.path.dirname(os.path.dirname(targetdir)).encode()

    filename = os.path.join(targetdir, "client-easy-openvpn.conf")
    openfile = open(filename, "wb")
    openfile.writelines([
        b"ca ca-crt.pem\n",
        b"cert client.crt+chain.pem\n",
        b"key client.key.pem\n",
    ])
    openfile.close();


def makedefaultserverconfig(targetdir):
    """
    write server's config particular to easy-openvpn into the specified
    directory
    """
    os.mkdir(os.path.join(targetdir, DIR_CLIENTCONFIG))
    os.mkdir(os.path.join(targetdir, DIR_CRL))

    filename = os.path.join(targetdir, "server-easy-openvpn.conf")
    openfile = open(filename, "wb")
    openfile.writelines([
        b"dh dh2048.pem\n",
        b"ca ca-crt.pem\n",
        b"crl-verify "+DIR_CRL.encode()+b"/ dir\n",
        b"cert server.crt+chain.pem\n",
        b"key server.key.pem\n",
        b"client-config-dir "+DIR_CLIENTCONFIG.encode()+b"/\n",
        b"ccd-exclusive\n",
    ])
    os.chmod(filename, 0o600)


def command_client_genall(args):
    """
    args.workdir ... path to the easy-openvpn directory
    args.name ... the new client's name
    :return: os.EX_ code
    """

    # if no client-config exists for this name, create (empty!) file
    cfgfile = os.path.join(args.workdir, DIR_CLIENTCONFIG, args.name)
    if not os.path.exists(cfgfile):
        open(cfgfile, 'a').close()

    return gencert(args.workdir, 'client', args.name)

command_client_genall.argParser = argparse.ArgumentParser(
    description='generate client keypair')
command_client_genall.argParser.add_argument('--name', nargs='?', required=True,
    help='the name of the client certificate (common name)')

def command_client_makebundle(args):
    """
    args.workdir ... path to the easy-openvpn directory
    args.name ... the new client's name
    args.output ... (optional) the name of tar.gz to be created
    :return: os.EX_ code
    """

    with open(os.path.join(args.workdir, 'NAME'), 'r') as fp:
        vpnname = fp.readline().rstrip("\n")

    # fill in default if args.output not specified
    if not args.output:
        args.output = args.name + ".tar.gz"

    cacrtfilepath = os.path.join(args.workdir, DIR_ROOTCA, "crt.pem")

    skelconfdirpath = os.path.join(args.workdir, DIR_CLIENTSKEL)

    dirpath = os.path.join(args.workdir, DIR_CLIENTS, args.name)
    crtfilepath = os.path.join(dirpath, "crt.pem")
    chainfilepath = os.path.join(dirpath, "crt+chain.pem")
    keyfilepath = os.path.join(dirpath, "key.pem")
    
    bundledirpath = os.path.join(dirpath, "bundle")
    conffilepath = os.path.join(bundledirpath, "client-easy-openvpn.conf")

    bundlecafilename = "ca-crt.pem"
    bundlecafilepath = os.path.join(bundledirpath, bundlecafilename)
    bundlecrtfilename = args.name + "-crt+chain.pem"
    bundlecrtfilepath = os.path.join(bundledirpath, bundlecrtfilename)
    bundlekeyfilename = args.name + "-key.pem"
    bundlekeyfilepath = os.path.join(bundledirpath, bundlekeyfilename)

    if os.path.isdir(bundledirpath):
        shutil.rmtree(bundledirpath)
    shutil.copytree(skelconfdirpath, bundledirpath)
    conffile = open(conffilepath, 'ab')
    conffile.writelines([
        b"\n",
        b"ca " + bundlecafilename.encode() + b"\n",
        b"cert " + bundlecrtfilename.encode() + b"\n",
        b"key " + bundlekeyfilename.encode() + b"\n",
    ])
    conffile.close()

    if os.path.exists(bundlecafilepath):
        shutil.rmtree(bundlecafilepath)
    shutil.copy(cacrtfilepath, bundlecafilepath)

    if os.path.exists(bundlecrtfilepath):
        shutil.rmtree(bundlecrtfilepath)
    shutil.copy(chainfilepath, bundlecrtfilepath)

    if os.path.exists(bundlekeyfilepath):
        shutil.rmtree(bundlekeyfilepath)
    shutil.copy(keyfilepath, bundlekeyfilepath)

    outtarfile = tarfile.open(args.output, 'w:gz')

    outtarfile.add(bundledirpath, arcname=vpnname)
    outtarfile.close()

    pass

command_client_makebundle.argParser = argparse.ArgumentParser(
    description='generate openvpn client config tar.gz')
command_client_makebundle.argParser.add_argument('--name', nargs='?',
    required=True,
    help='the name of the client certificate (common name)')
command_client_makebundle.argParser.add_argument('--output', nargs='?',
    help='output into file instead of stdout')

def command_init(args):
    """
    args.workdir ... path to the easy-openvpn directory
    :return: os.EX_ code
    """
    if len( os.listdir(path=args.workdir) ) != 0:
        print("Error: directory not empty!", file=sys.stderr)
        return os.EX_CANTCREAT

    workdir = args.workdir
    rootcadir = os.path.join(workdir, DIR_ROOTCA)
    intcadir = os.path.join(workdir, DIR_INTCA)
    clientsdir = os.path.join(workdir, DIR_CLIENTS)
    serversdir = os.path.join(workdir, DIR_SERVERS)
    clientconfigskeldir = os.path.join(workdir, DIR_CLIENTSKEL)

    os.mkdir(rootcadir)
    os.mkdir(intcadir)
    os.mkdir(clientsdir)
    os.mkdir(serversdir)
    os.mkdir(clientconfigskeldir)

    genrootca(rootcadir)
    genintca(rootcadir, intcadir)
    gendhparams(args.workdir)
    makedefaultclientconfig(clientconfigskeldir)
    makedefaultserverconfig(workdir)

    os.symlink(
        os.path.join(DIR_ROOTCA,"crt.pem"),
        os.path.join(args.workdir, "ca-crt.pem"))

    args.name = socket.getfqdn()

    with open(os.path.join(args.workdir, 'NAME'), 'w') as fp:
        fp.write(args.name)
        fp.write("\n")
    command_server_genall(args)

    return os.EX_OK

command_init.argParser = argparse.ArgumentParser(
    description='initialize easy-openvpn/ directory')


def command_server_genall(args):
    """
    args.workdir ... path to the easy-openvpn directory
    args.name ... the new server's name
    :return: os.EX_ code
    """

    # if this is the first server cert, automatically make symlink
    target = os.path.join(DIR_SERVERS, args.name, "crt+chain.pem")
    symlinkfile = os.path.join(args.workdir, "server.crt+chain.pem")
    if not os.path.exists(symlinkfile):
        os.symlink(target, symlinkfile)
    target = os.path.join(DIR_SERVERS, args.name, "key.pem")
    symlinkfile = os.path.join(args.workdir, "server.key.pem")
    if not os.path.exists(symlinkfile):
        os.symlink(target, symlinkfile)

    # always generate new cert in the separate dir
    return gencert(args.workdir, 'server', args.name)

command_server_genall.argParser = argparse.ArgumentParser(
    description='generate server keypair')
command_server_genall.argParser.add_argument('--name', nargs='?', required=True,
    help='the name of the server certificate (common name)')




os.umask(0o077)

# special parse first argument, which must be a command that is not prefixed
# with double dash '--'
if len(sys.argv) <= 1:
    usage()
    sys.exit(os.EX_USAGE)

commands = {
    "client-genall": command_client_genall,
    "client-makebundle": command_client_makebundle,
    "init": command_init,
    "server-genall": command_server_genall,
}
if sys.argv[1] not in commands:
    print("Error: Unknown command: {}".format(sys.argv[1]), file=sys.stderr)
    usage()
    sys.exit(os.EX_USAGE)
command = commands[sys.argv[1]]
sys.argv = sys.argv[1:]

args = command.argParser.parse_args()
args.workdir = os.getcwd()
ret = command(args)

sys.exit(ret)
