#!/usr/bin/env python3

import argparse
import logging
import os
import time

from OpenSSL import crypto

from prometheus_client import start_http_server
from prometheus_client.core import GaugeMetricFamily, REGISTRY

from pyasn1.type.useful import GeneralizedTime


def parse_args():
    """Return the parsed arguments."""
    parser = argparse.ArgumentParser()

    parser.add_argument(
        '-f',
        '--watch-file',
        action='append',
        metavar='FILE_PATH',
        help='one or more x509 certificate file')
    parser.add_argument(
        '-d',
        '--watch-dir',
        action='append',
        metavar='DIR_PATH',
        help='one or more directory which contains x509 certificate files')
    parser.add_argument(
        '-p',
        '--port',
        default=9090,
        metavar='PORT',
        type=int,
        help='prometheus exporter listening port')

    parser.add_argument(
        '--debug',
        action='store_true',
        help='enable debug mode')

    return parser.parse_args()


def parse_x509(file):
    """x509 parsing helper."""
    return crypto.load_certificate(crypto.FILETYPE_PEM, file.read())


def walk_files_and_dirs(args):
    """Compile a deduped list of files."""
    x509_filepaths = set([])
    if args.watch_file:
        for filename in args.watch_file:
            try:
                os.stat(filename)
                x509_filepaths.add(filename)
            except:
                logging.warning("not able to stat file: %s" % filename)
    if args.watch_dir:
        for directory in args.watch_dir:
            for root, subdirectories, filenames in os.walk(directory):
                for filename in filenames:
                    x509_filepaths.add(os.path.join(root, filename))
    return x509_filepaths


def check_certificates(x509_filepaths):
    """Verify file content and sanitize the filepaths."""
    valid_certificates = []
    for x509_filepath in x509_filepaths:
        with open(x509_filepath, 'rt') as file:
            try:
                parse_x509(file)
                logging.info(
                    "valid x509 certificate detected: %s"
                    % x509_filepath)
                valid_certificates += [x509_filepath]
            except:
                logging.warning("not able to parse: %s" % x509_filepath)
    return valid_certificates


class X509Collector:
    """a prometheus x509 collector."""

    def __init__(self, x509_filepaths):
        """Take a set of filepaths as argument."""
        self.x509_filepaths = x509_filepaths

    def serialize_x509name(self, x509name):
        """Helper to serialize a x509Name as a string."""
        return b' '.join(
            b'='.join(component) for component in x509name.get_components()
        ).decode('utf-8')

    def add_x509name_components_labels(self, x509name, labels, prefix):
        """Add x509 name components to label list."""
        if x509name.C:
            labels[prefix + '_C'] = x509name.C
        if x509name.ST:
            labels[prefix + '_ST'] = x509name.ST
        if x509name.L:
            labels[prefix + '_L'] = x509name.L
        if x509name.O:
            labels[prefix + '_O'] = x509name.O
        if x509name.OU:
            labels[prefix + '_OU'] = x509name.OU
        if x509name.CN:
            labels[prefix + '_CN'] = x509name.CN

    def build_labels(self, x509, x509_filepath):
        """Return prometheus metric labels."""
        labels = {
            'filename': os.path.basename(x509_filepath),
            'filepath': x509_filepath,
            'serial_number': "%s" % x509.get_serial_number(),
        }
        self.add_x509name_components_labels(
            x509.get_issuer(),
            labels, "issuer")
        self.add_x509name_components_labels(
            x509.get_subject(),
            labels, "subject")
        return labels

    def build_metrics(self, x509):
        """Return prometheus metrics."""
        x509_not_before_asn1_time = GeneralizedTime(x509.get_notBefore())
        x509_not_before_ts = x509_not_before_asn1_time.asDateTime.timestamp()

        x509_not_after_asn1_time = GeneralizedTime(x509.get_notAfter())
        x509_not_after_ts = x509_not_after_asn1_time.asDateTime.timestamp()

        expired = float(x509.has_expired())

        return (x509_not_before_ts, x509_not_after_ts, expired)

    def collect(self):
        """The main Prometheus collector function."""
        for x509_filepath in self.x509_filepaths:
            with open(x509_filepath, 'rt') as file:
                try:
                    x509 = parse_x509(file)
                    labels = self.build_labels(x509, x509_filepath)

                    not_before, not_after, expired = self.build_metrics(x509)

                    metric = GaugeMetricFamily(
                        'x509_cert_not_before',
                        "x509 certificate not_before timestamp",
                        labels=labels.keys())
                    metric.add_metric(labels.values(), not_before)
                    yield metric

                    metric = GaugeMetricFamily(
                        'x509_cert_not_after',
                        "x509 certificate not_after timestamp",
                        labels=labels.keys())
                    metric.add_metric(labels.values(), not_after)
                    yield metric

                    metric = GaugeMetricFamily(
                        'x509_cert_expired',
                        "x509 certificate expiration boolean",
                        labels=labels.keys())
                    metric.add_metric(labels.values(), expired)
                    yield metric
                except:
                    logging.warning(
                        "not able to parse and compute: %s"
                        % x509_filepath)

LOOP_SLEEP_TIME = 300


def main():
    """x509-exporter main function."""
    args = parse_args()
    logging.basicConfig(
        level=logging.DEBUG if args.debug else logging.INFO,
        format='%(asctime)s %(levelname)s %(message)s')

    x509_filepaths = walk_files_and_dirs(args)
    x509_filepaths = check_certificates(x509_filepaths)
    logging.info("monitoring %d certificate(s)" % len(x509_filepaths))
    REGISTRY.register(X509Collector(x509_filepaths))
    logging.info("starting exporter on port %d" % args.port)
    start_http_server(args.port)
    while True:
        time.sleep(LOOP_SLEEP_TIME)

if __name__ == '__main__':
    main()
