diff --git a/.gitignore b/.gitignore index 29d639f..6c829c8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ -.coverage +.coverage* .cache nosetests.xml coverage.xml diff --git a/.travis.yml b/.travis.yml index 41ecd43..9aed2d3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,8 +10,9 @@ virtualenv: install: - echo "127.0.0.1 localhost" | sudo tee /etc/hosts - echo "127.0.1.1 ca.example.lan ca" | sudo tee -a /etc/hosts - - echo "127.0.0.1 vpn.koodur.lan" | sudo tee -a /etc/hosts - - sudo mkdir -p /etc/systemd/system /etc/NetworkManager/system-connections + - echo "127.0.0.1 vpn.example.lan vpn" | sudo tee -a /etc/hosts + - echo "127.0.0.1 www.example.lan www" | sudo tee -a /etc/hosts + - sudo mkdir -p /etc/systemd/system - sudo pip install -r requirements.txt - sudo pip install codecov pytest-cov - sudo pip install -e . diff --git a/certidude/api/__init__.py b/certidude/api/__init__.py index 393cb10..fc21c25 100644 --- a/certidude/api/__init__.py +++ b/certidude/api/__init__.py @@ -154,7 +154,7 @@ class StaticResource(object): else: resp.status = falcon.HTTP_404 resp.body = "File '%s' not found" % req.path - logger.info("Faile '%s' not found, path resolved to '%s'", req.path, path) + logger.info("Fail '%s' not found, path resolved to '%s'", req.path, path) import ipaddress class NormalizeMiddleware(object): diff --git a/certidude/api/request.py b/certidude/api/request.py index edb6cd9..59c6fe0 100644 --- a/certidude/api/request.py +++ b/certidude/api/request.py @@ -29,6 +29,7 @@ class RequestListResource(object): """ Validate and parse certificate signing request """ + reason = "No reason" body = req.stream.read(req.content_length) csr = x509.load_pem_x509_csr(body, default_backend()) try: @@ -87,14 +88,15 @@ class RequestListResource(object): verifier.verify() except InvalidSignature: logger.error("Renewal failed, invalid signature supplied for %s", common_name.value) + reason = "Renewal failed, invalid signature supplied" else: # At this point renewal signature was valid but we need to perform some extra checks if datetime.utcnow() > cert.not_valid_after: logger.error("Renewal failed, current certificate for %s has expired", common_name.value) - # Put on hold + reason = "Renewal failed, current certificate expired" elif not config.CERTIFICATE_RENEWAL_ALLOWED: logger.error("Renewal requested for %s, but not allowed by authority settings", common_name.value) - # Put on hold + reason = "Renewal requested, but not allowed by authority settings" else: resp.set_header("Content-Type", "application/x-x509-user-cert") _, resp.body = authority._sign(csr, body, overwrite=True) @@ -106,25 +108,30 @@ class RequestListResource(object): Process automatic signing if the IP address is whitelisted, autosigning was requested and certificate can be automatically signed """ - if req.get_param_as_bool("autosign") and "." not in common_name.value: - for subnet in config.AUTOSIGN_SUBNETS: - if req.context.get("remote_addr") in subnet: - try: - resp.set_header("Content-Type", "application/x-pem-file") - _, resp.body = authority._sign(csr, body) - logger.info("Autosigned %s as %s is whitelisted", common_name.value, req.context.get("remote_addr")) - return - except EnvironmentError: - logger.info("Autosign for %s failed, signed certificate already exists", - common_name.value, req.context.get("remote_addr")) - break + if req.get_param_as_bool("autosign"): + if "." not in common_name.value: + reason = "Autosign failed, IP address not whitelisted" + for subnet in config.AUTOSIGN_SUBNETS: + if req.context.get("remote_addr") in subnet: + try: + resp.set_header("Content-Type", "application/x-pem-file") + _, resp.body = authority._sign(csr, body) + logger.info("Autosigned %s as %s is whitelisted", common_name.value, req.context.get("remote_addr")) + return + except EnvironmentError: + logger.info("Autosign for %s failed, signed certificate already exists", + common_name.value, req.context.get("remote_addr")) + reason = "Autosign failed, signed certificate already exists" + break + else: + reason = "Autosign failed, only client certificates allowed to be signed automatically" # Attempt to save the request otherwise try: csr = authority.store_request(body) except errors.RequestExists: + reason = "Same request already uploaded exists" # We should still redirect client to long poll URL below - pass except errors.DuplicateCommonNameError: # TODO: Certificate renewal logger.warning(u"Rejected signing request with overlapping common name from %s", @@ -147,6 +154,7 @@ class RequestListResource(object): else: # Request was accepted, but not processed resp.status = falcon.HTTP_202 + resp.body = reason class RequestDetailResource(object): diff --git a/certidude/cli.py b/certidude/cli.py index 8d4d91e..f133159 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -15,7 +15,7 @@ import subprocess import sys from configparser import ConfigParser, NoOptionError, NoSectionError from certidude.helpers import certidude_request_certificate -from certidude.common import expand_paths, ip_address, ip_network, apt, rpm, pip, drop_privileges +from certidude.common import ip_address, ip_network, apt, rpm, pip, drop_privileges from datetime import datetime, timedelta from time import sleep import const @@ -31,6 +31,56 @@ logger = logging.getLogger(__name__) NOW = datetime.utcnow().replace(tzinfo=None) +def setup_client(prefix="client_"): + # Create section in /etc/certidude/client.conf + def wrapper(func): + def wrapped(**arguments): + from certidude import const + common_name = arguments.get("common_name") + authority = arguments.get("authority") + b = os.path.join(const.STORAGE_PATH, authority) + + # Create corresponding section in Certidude client configuration file + client_config = ConfigParser() + if os.path.exists(const.CLIENT_CONFIG_PATH): + client_config.readfp(open(const.CLIENT_CONFIG_PATH)) + if client_config.has_section(authority): + click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) + else: + client_config.add_section(authority) + client_config.set(authority, "trigger", "interface up") + client_config.set(authority, "common name", common_name) + client_config.set(authority, "request path", os.path.join(b, prefix + "req.pem")) + client_config.set(authority, "key path", os.path.join(b, prefix + "key.pem")) + client_config.set(authority, "certificate path", os.path.join(b, prefix + "cert.pem")) + client_config.set(authority, "authority path", os.path.join(b, "ca_cert.pem")) + client_config.set(authority, "revocations path", os.path.join(b, "ca_crl.pem")) + with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: + client_config.write(fh) + os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) + + for j in ("key", "request", "certificate", "authority", "revocations"): + arguments["%s_path" % j] = client_config.get(authority, "%s path" % j) + return func(**arguments) + return wrapped + return wrapper + + +def generate_dhparam(path): + # Prevent logjam etc for OpenVPN and nginx server + def wrapper(func): + def wrapped(**arguments): + if not os.path.exists(path): + rpm("openssl") + apt("openssl") + cmd = "openssl", "dhparam", "-out", path, ("1024" if os.getenv("TRAVIS") else "2048") + subprocess.check_call(cmd) + arguments["dhparam_path"] = path + return func(**arguments) + return wrapped + return wrapper + @click.command("request", help="Run processes for requesting certificates and configuring services") @click.option("-r", "--renew", default=False, is_flag=True, help="Renew now") @click.option("-f", "--fork", default=False, is_flag=True, help="Fork to background") @@ -46,15 +96,12 @@ def certidude_request(fork, renew, no_wait): click.echo("No %s!" % const.CLIENT_CONFIG_PATH) return 1 - if not os.path.exists(const.SERVICES_CONFIG_PATH): - click.echo("No %s!" % const.SERVICES_CONFIG_PATH) - return 1 - clients = ConfigParser() clients.readfp(open(const.CLIENT_CONFIG_PATH)) service_config = ConfigParser() - service_config.readfp(open(const.SERVICES_CONFIG_PATH)) + if os.path.exists(const.SERVICES_CONFIG_PATH): + service_config.readfp(open(const.SERVICES_CONFIG_PATH)) # Process directories if not os.path.exists(const.RUN_DIR): @@ -76,13 +123,9 @@ def certidude_request(fork, renew, no_wait): for authority in clients.sections(): try: - endpoint_dhparam = clients.get(authority, "dhparam path") - if not os.path.exists(endpoint_dhparam): - cmd = "openssl", "dhparam", "-out", endpoint_dhparam, ("512" if os.getenv("TRAVIS") else "2048") - subprocess.check_call(cmd) + endpoint_renewal_overlap = clients.getint(authority, "renewal overlap") except NoOptionError: - pass - + endpoint_renewal_overlap = None try: endpoint_insecure = clients.getboolean(authority, "insecure") except NoOptionError: @@ -157,6 +200,7 @@ def certidude_request(fork, renew, no_wait): endpoint_authority_path, endpoint_revocations_path, endpoint_common_name, + endpoint_renewal_overlap, insecure=endpoint_insecure, autosign=True, wait=not no_wait, @@ -196,7 +240,6 @@ def certidude_request(fork, renew, no_wait): # IPSec set up with initscripts if service_config.get(endpoint, "service") == "init/strongswan": - remote = service_config.get(endpoint, "remote") from ipsecparse import loads config = loads(open("%s/ipsec.conf" % const.STRONGSWAN_PREFIX).read()) for section_type, section_name in config: @@ -286,7 +329,8 @@ def certidude_request(fork, renew, no_wait): with open(nm_config_path, "w") as fh: nm_config.write(fh) click.echo("Created %s" % nm_config_path) - os.system("nmcli con reload") + if os.path.exists("/run/NetworkManager"): + os.system("nmcli con reload") continue @@ -323,7 +367,8 @@ def certidude_request(fork, renew, no_wait): with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as fh: nm_config.write(fh) click.echo("Created %s" % fh.name) - os.system("nmcli con reload") + if os.path.exists("/run/NetworkManager"): + os.system("nmcli con reload") continue # TODO: Puppet, OpenLDAP, @@ -343,34 +388,13 @@ def certidude_request(fork, renew, no_wait): default="/etc/openvpn/site-to-client.conf", type=click.File(mode="w", atomic=True, lazy=True), help="OpenVPN configuration file") -def certidude_setup_openvpn_server(authority, common_name, config, subnet, route, local, proto, port): +@generate_dhparam("/etc/openvpn/dh.pem") +@setup_client(prefix="server_") +def certidude_setup_openvpn_server(authority, common_name, config, subnet, route, local, proto, port, **paths): # Install dependencies apt("openvpn") rpm("openvpn") - # Create corresponding section in Certidude client configuration file - client_config = ConfigParser() - if os.path.exists(const.CLIENT_CONFIG_PATH): - client_config.readfp(open(const.CLIENT_CONFIG_PATH)) - if client_config.has_section(authority): - click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) - else: - client_config.add_section(authority) - client_config.set(authority, "trigger", "interface up") - client_config.set(authority, "common name", common_name) - slug = common_name.replace(".", "-") - client_config.set(authority, "request path", "/etc/openvpn/keys/%s.csr" % slug) - client_config.set(authority, "key path", "/etc/openvpn/keys/%s.key" % slug) - client_config.set(authority, "certificate path", "/etc/openvpn/keys/%s.crt" % slug) - client_config.set(authority, "authority path", "/etc/openvpn/keys/ca.crt") - client_config.set(authority, "revocations path", "/etc/openvpn/keys/ca.crl") - client_config.set(authority, "dhparam path", "/etc/openvpn/keys/dhparam.pem") - with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: - client_config.write(fh) - os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) - click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) - - # Create corresponding section in /etc/certidude/services.conf endpoint = "OpenVPN server %s of %s" % (common_name, authority) service_config = ConfigParser() @@ -394,17 +418,18 @@ def certidude_setup_openvpn_server(authority, common_name, config, subnet, route config.write("proto %s\n" % proto) config.write("port %d\n" % port) config.write("local %s\n" % local) - config.write("key %s\n" % client_config.get(authority, "key path")) - config.write("cert %s\n" % client_config.get(authority, "certificate path")) - config.write("ca %s\n" % client_config.get(authority, "authority path")) - config.write("dh %s\n" % client_config.get(authority, "dhparam path")) + config.write("key %s\n" % paths.get("key_path")) + config.write("cert %s\n" % paths.get("certificate_path")) + config.write("ca %s\n" % paths.get("authority_path")) + config.write("crl-verify %s\n" % paths.get("revocations_path")) + config.write("dh %s\n" % paths.get("dhparam_path")) config.write("comp-lzo\n") config.write("user nobody\n") config.write("group nogroup\n") config.write("persist-tun\n") config.write("persist-key\n") config.write("#ifconfig-pool-persist /tmp/openvpn-leases.txt\n") - config.write("#crl-verify %s\n" % client_config.get(authority, "revocations path")) + click.echo("Generated %s" % config.name) click.echo("Inspect generated files and issue following to request certificate:") @@ -423,18 +448,14 @@ def certidude_setup_openvpn_server(authority, common_name, config, subnet, route default="/etc/nginx/sites-available/%s.conf" % const.HOSTNAME, type=click.File(mode="w", atomic=True, lazy=True), help="Site configuration file of nginx, /etc/nginx/sites-available/%s.conf by default" % const.HOSTNAME) -@click.option("--directory", "-d", default="/etc/nginx/ssl", help="Directory for keys, /etc/nginx/ssl by default") -@click.option("--key-path", "-key", default=const.HOSTNAME + ".key", help="Key path, %s.key relative to -d by default" % const.HOSTNAME) -@click.option("--request-path", "-csr", default=const.HOSTNAME + ".csr", help="Request path, %s.csr relative to -d by default" % const.HOSTNAME) -@click.option("--certificate-path", "-crt", default=const.HOSTNAME + ".crt", help="Certificate path, %s.crt relative to -d by default" % const.HOSTNAME) -@click.option("--dhparam-path", "-dh", default="dhparam2048.pem", help="Diffie/Hellman parameters path, dhparam2048.pem relative to -d by default") -@click.option("--authority-path", "-ca", default="ca.crt", help="Certificate authority certificate path, ca.crt relative to -d by default") -@click.option("--revocations-path", "-crl", default="ca.crl", help="Certificate revocation list, ca.crl relative to -d by default") @click.option("--verify-client", "-vc", default="optional", type=click.Choice(['optional', 'on', 'off'])) -@expand_paths() -def certidude_setup_nginx(authority, site_config, tls_config, common_name, directory, key_path, request_path, certificate_path, authority_path, revocations_path, dhparam_path, verify_client): - if not os.path.exists("/etc/nginx"): - raise ValueError("nginx not installed") +@generate_dhparam("/etc/nginx/ssl/dh.pem") +@setup_client(prefix="server_") +def certidude_setup_nginx(authority, common_name, site_config, tls_config, verify_client, **paths): + apt("nginx") + rpm("nginx") + from jinja2 import Environment, PackageLoader + env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True) if "." not in common_name: raise ValueError("Fully qualified hostname not specified as common name, make sure hostname -f works") client_config = ConfigParser() @@ -450,7 +471,6 @@ def certidude_setup_nginx(authority, site_config, tls_config, common_name, direc client_config.set(authority, "key path", key_path) client_config.set(authority, "certificate path", certificate_path) client_config.set(authority, "authority path", authority_path) - client_config.set(authority, "dhparam path", dhparam_path) client_config.set(authority, "revocations path", revocations_path) with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: client_config.write(fh) @@ -459,6 +479,7 @@ def certidude_setup_nginx(authority, site_config, tls_config, common_name, direc context = globals() # Grab const.BLAH context.update(locals()) + context.update(paths) if os.path.exists(site_config.name): click.echo("Configuration file %s already exists, not overwriting" % site_config.name) @@ -492,30 +513,12 @@ def certidude_setup_nginx(authority, site_config, tls_config, common_name, direc default="/etc/openvpn/client-to-site.conf", type=click.File(mode="w", atomic=True, lazy=True), help="OpenVPN configuration file") -def certidude_setup_openvpn_client(authority, remote, common_name, config, proto): +@setup_client() +def certidude_setup_openvpn_client(authority, remote, common_name, config, proto, **ctx): # Install dependencies apt("openvpn") rpm("openvpn") - # Create corresponding section in Certidude client configuration file - client_config = ConfigParser() - if os.path.exists(const.CLIENT_CONFIG_PATH): - client_config.readfp(open(const.CLIENT_CONFIG_PATH)) - if client_config.has_section(authority): - click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) - else: - client_config.add_section(authority) - client_config.set(authority, "trigger", "interface up") - client_config.set(authority, "common name", common_name) - client_config.set(authority, "request path", "/etc/openvpn/keys/%s.csr" % const.common_name) - client_config.set(authority, "key path", "/etc/openvpn/keys/%s.key" % common_name) - client_config.set(authority, "certificate path", "/etc/openvpn/keys/%s.crt" % common_name) - client_config.set(authority, "authority path", "/etc/openvpn/keys/ca.crt") - client_config.set(authority, "revocations path", "/etc/openvpn/keys/ca.crl") - with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: - client_config.write(fh) - os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) - click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) # Create corresponding section in /etc/certidude/services.conf endpoint = "OpenVPN to %s" % remote @@ -538,7 +541,7 @@ def certidude_setup_openvpn_client(authority, remote, common_name, config, proto config.write("remote %s\n" % remote) config.write("remote-cert-tls server\n") config.write("proto %s\n" % proto) - config.write("dev tun\n") + config.write("dev tun-%s\n" % remote.split(".")[0]) config.write("nobind\n") config.write("key %s\n" % client_config.get(authority, "key path")) config.write("cert %s\n" % client_config.get(authority, "certificate path")) @@ -563,7 +566,8 @@ def certidude_setup_openvpn_client(authority, remote, common_name, config, proto @click.option("--common-name", "-cn", default=const.FQDN, help="Common name, %s by default" % const.FQDN) @click.option("--subnet", "-sn", default=u"192.168.33.0/24", type=ip_network, help="IPsec virtual subnet, 192.168.33.0/24 by default") @click.option("--route", "-r", type=ip_network, multiple=True, help="Subnets to advertise via this connection, multiple allowed") -def certidude_setup_strongswan_server(authority, common_name, subnet, route): +@setup_client(prefix="server_") +def certidude_setup_strongswan_server(authority, common_name, subnet, route, **paths): if "." not in common_name: raise ValueError("Hostname has to be fully qualified!") @@ -572,31 +576,27 @@ def certidude_setup_strongswan_server(authority, common_name, subnet, route): rpm("strongswan") pip("ipsecparse") - # Create corresponding section in Certidude client configuration file - client_config = ConfigParser() - if os.path.exists(const.CLIENT_CONFIG_PATH): - client_config.readfp(open(const.CLIENT_CONFIG_PATH)) - if client_config.has_section(authority): - click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) + # Create corresponding section in /etc/certidude/services.conf + endpoint = "IPsec gateway for %s" % authority + service_config = ConfigParser() + if os.path.exists(const.SERVICES_CONFIG_PATH): + service_config.readfp(open(const.SERVICES_CONFIG_PATH)) + if service_config.has_section(endpoint): + click.echo("Section '%s' already exists in %s, not reconfiguring" % (endpoint, const.SERVICES_CONFIG_PATH)) else: - client_config.add_section(authority) - client_config.set(authority, "trigger", "interface up") - client_config.set(authority, "common name", const.FQDN) - client_config.set(authority, "request path", "%s/ipsec.d/reqs/%s.pem" % (const.STRONGSWAN_PREFIX, const.HOSTNAME)) - client_config.set(authority, "key path", "%s/ipsec.d/private/%s.pem" % (const.STRONGSWAN_PREFIX, const.HOSTNAME)) - client_config.set(authority, "certificate path", "%s/ipsec.d/certs/%s.pem" % (const.STRONGSWAN_PREFIX, const.HOSTNAME)) - client_config.set(authority, "authority path", "%s/ipsec.d/cacerts/ca.pem" % const.STRONGSWAN_PREFIX) - client_config.set(authority, "revocations path", "%s/ipsec.d/crls/ca.pem" % const.STRONGSWAN_PREFIX) - with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: - client_config.write(fh) - os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) - click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) + service_config.add_section(endpoint) + service_config.set(endpoint, "authority", authority) + service_config.set(endpoint, "service", "init/strongswan") + with open(const.SERVICES_CONFIG_PATH + ".part", 'wb') as fh: + service_config.write(fh) + os.rename(const.SERVICES_CONFIG_PATH + ".part", const.SERVICES_CONFIG_PATH) + click.echo("Section '%s' added to %s" % (endpoint, const.SERVICES_CONFIG_PATH)) # Create corresponding section to /etc/ipsec.conf from ipsecparse import loads config = loads(open("%s/ipsec.conf" % const.STRONGSWAN_PREFIX).read()) config["conn", authority] = dict( - leftcert=client_config.get(authority, "certificate path"), + leftcert=paths.get("certificate_path"), leftsubnet=",".join(route), right="%any", rightsourceip=str(subnet), @@ -617,32 +617,13 @@ def certidude_setup_strongswan_server(authority, common_name, subnet, route): @click.argument("authority") @click.argument("remote") @click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, %s by default" % const.HOSTNAME) -def certidude_setup_strongswan_client(authority, remote, common_name): +@setup_client() +def certidude_setup_strongswan_client(authority, remote, common_name, **paths): # Install dependencies apt("strongswan") rpm("strongswan") pip("ipsecparse") - # Create corresponding section in /etc/certidude/client.conf - client_config = ConfigParser() - if os.path.exists(const.CLIENT_CONFIG_PATH): - client_config.readfp(open(const.CLIENT_CONFIG_PATH)) - if client_config.has_section(authority): - click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) - else: - client_config.add_section(authority) - client_config.set(authority, "trigger", "interface up") - client_config.set(authority, "common name", common_name) - client_config.set(authority, "request path", "%s/ipsec.d/reqs/%s.pem" % (const.STRONGSWAN_PREFIX, common_name)) - client_config.set(authority, "key path", "%s/ipsec.d/private/%s.pem" % (const.STRONGSWAN_PREFIX, common_name)) - client_config.set(authority, "certificate path", "%s/ipsec.d/certs/%s.pem" % (const.STRONGSWAN_PREFIX, common_name)) - client_config.set(authority, "authority path", "%s/ipsec.d/cacerts/ca.pem" % const.STRONGSWAN_PREFIX) - client_config.set(authority, "revocations path", "%s/ipsec.d/crls/ca.pem" % const.STRONGSWAN_PREFIX) - with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: - client_config.write(fh) - os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) - click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) - # Create corresponding section in /etc/certidude/services.conf endpoint = "IPsec connection to %s" % remote service_config = ConfigParser() @@ -666,7 +647,7 @@ def certidude_setup_strongswan_client(authority, remote, common_name): config["conn", remote] = dict( leftsourceip="%config", left="%defaultroute", - leftcert=client_config.get(authority, "certificate path"), + leftcert=paths.get("certificate_path"), rightid="%any", right=remote, #rightsubnet=route, @@ -689,33 +670,14 @@ def certidude_setup_strongswan_client(authority, remote, common_name): @click.argument("authority") # Certidude server @click.argument("remote") # StrongSwan gateway @click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, %s by default" % const.HOSTNAME) -def certidude_setup_strongswan_networkmanager(authority, remote, common_name): +@setup_client() +def certidude_setup_strongswan_networkmanager(authority, remote, common_name, **paths): # Install dependencies apt("strongswan-nm") rpm("NetworkManager-strongswan-gnome") endpoint = "IPSec to %s" % remote - # Create corresponding section in /etc/certidude/client.conf - client_config = ConfigParser() - if os.path.exists(const.CLIENT_CONFIG_PATH): - client_config.readfp(open(const.CLIENT_CONFIG_PATH)) - if client_config.has_section(authority): - click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) - else: - client_config.add_section(authority) - client_config.set(authority, "trigger", "interface up") - client_config.set(authority, "common name", common_name) - client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % common_name) - client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % common_name) - client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % common_name) - client_config.set(authority, "authority path", "/etc/ipsec.d/cacerts/ca.pem") - client_config.set(authority, "revocations path", "/etc/ipsec.d/crls/ca.pem") - with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: - client_config.write(fh) - os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) - click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) - # Create corresponding section in /etc/certidude/services.conf service_config = ConfigParser() if os.path.exists(const.SERVICES_CONFIG_PATH): @@ -737,27 +699,9 @@ def certidude_setup_strongswan_networkmanager(authority, remote, common_name): @click.argument("authority") @click.argument("remote") # OpenVPN gateway @click.option("--common-name", "-cn", default=const.HOSTNAME, help="Common name, %s by default" % const.HOSTNAME) -def certidude_setup_openvpn_networkmanager(authority, remote, common_name): - # Create corresponding section in /etc/certidude/client.conf - client_config = ConfigParser() - if os.path.exists(const.CLIENT_CONFIG_PATH): - client_config.readfp(open(const.CLIENT_CONFIG_PATH)) - if client_config.has_section(authority): - click.echo("Section '%s' already exists in %s, remove to regenerate" % (authority, const.CLIENT_CONFIG_PATH)) - else: - client_config.add_section(authority) - client_config.set(authority, "trigger", "interface up") - client_config.set(authority, "common name", common_name) - client_config.set(authority, "request path", "/etc/ipsec.d/reqs/%s.pem" % common_name) - client_config.set(authority, "key path", "/etc/ipsec.d/private/%s.pem" % common_name) - client_config.set(authority, "certificate path", "/etc/ipsec.d/certs/%s.pem" % common_name) - client_config.set(authority, "authority path", "/etc/ipsec.d/cacerts/ca.pem") - client_config.set(authority, "revocations path", "/etc/ipsec.d/crls/ca.pem") - with open(const.CLIENT_CONFIG_PATH + ".part", 'wb') as fh: - client_config.write(fh) - os.rename(const.CLIENT_CONFIG_PATH + ".part", const.CLIENT_CONFIG_PATH) - click.echo("Section '%s' added to %s" % (authority, const.CLIENT_CONFIG_PATH)) - +@setup_client() +def certidude_setup_openvpn_networkmanager(authority, remote, common_name, **paths): + # Create corresponding section in /etc/certidude/services.conf endpoint = "OpenVPN to %s" % remote service_config = ConfigParser() @@ -767,7 +711,7 @@ def certidude_setup_openvpn_networkmanager(authority, remote, common_name): click.echo("Section '%s' already exists in %s, remove to regenerate" % (endpoint, const.SERVICES_CONFIG_PATH)) else: service_config.add_section(endpoint) - service_config.set(authority, "authority", server) + service_config.set(endpoint, "authority", authority) service_config.set(endpoint, "remote", remote) service_config.set(endpoint, "service", "network-manager/openvpn") service_config.write(open("/etc/certidude/services.conf", "w")) diff --git a/certidude/common.py b/certidude/common.py index 2d0db0d..95719da 100644 --- a/certidude/common.py +++ b/certidude/common.py @@ -32,33 +32,6 @@ def ip_address(j): import ipaddress return ipaddress.ip_address(unicode(j)) -def expand_paths(): - """ - Prefix '..._path' keyword arguments of target function with 'directory' keyword argument - and create the directory if necessary - - TODO: Move to separate file - """ - def wrapper(func): - def wrapped(**arguments): - d = arguments.get("directory") - for key, value in arguments.items(): - if key.endswith("_path"): - if d: - value = os.path.join(d, value) - value = os.path.realpath(value) - parent = os.path.dirname(value) - if not os.path.exists(parent): - click.echo("Making directory %s for %s" % (repr(parent), repr(key))) - os.makedirs(parent) - elif not os.path.isdir(parent): - raise Exception("Path %s is not directory!" % parent) - arguments[key] = value - return func(**arguments) - return wrapped - return wrapper - - def apt(packages): """ Install packages for Debian and Ubuntu diff --git a/certidude/const.py b/certidude/const.py index e258009..3fb893c 100644 --- a/certidude/const.py +++ b/certidude/const.py @@ -15,6 +15,7 @@ SERVER_LOG_PATH = "/var/log/certidude-server.log" SIGNER_SOCKET_PATH = "/run/certidude/signer.sock" SIGNER_PID_PATH = os.path.join(RUN_DIR, "signer.pid") SIGNER_LOG_PATH = "/var/log/certidude-signer.log" +STORAGE_PATH = "/var/lib/certidude" try: FQDN = socket.getaddrinfo(socket.gethostname(), 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME)[0][3] diff --git a/certidude/helpers.py b/certidude/helpers.py index 7be007e..6241812 100644 --- a/certidude/helpers.py +++ b/certidude/helpers.py @@ -16,12 +16,12 @@ def selinux_fixup(path): cmd = "chcon", "--type=home_cert_t", path subprocess.call(cmd) -def certidude_request_certificate(server, system_keytab_required, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, autosign=False, wait=False, bundle=False, renew=False, insecure=False): +def certidude_request_certificate(server, system_keytab_required, key_path, request_path, certificate_path, authority_path, revocations_path, common_name, renewal_overlap, autosign=False, wait=False, bundle=False, renew=False, insecure=False): """ Exchange CSR for certificate using Certidude HTTP API server """ import requests - from certidude import errors, const + from certidude import errors, const, config from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.backends import default_backend @@ -178,14 +178,15 @@ def certidude_request_certificate(server, system_keytab_required, key_path, requ cert_buf = open(certificate_path).read() cert = x509.load_pem_x509_certificate(cert_buf, default_backend()) lifetime = (cert.not_valid_after - cert.not_valid_before) - overlap = lifetime / 4 # TODO: Make overlap configurable - if datetime.now() > cert.not_valid_after - overlap: - click.echo("Certificate expired %s" % cert.not_valid_after) + if renewal_overlap and datetime.now() > cert.not_valid_after - timedelta(days=renewal_overlap): + click.echo("Certificate will expire %s, will attempt to renew" % cert.not_valid_after) renew = True else: click.echo("Found valid certificate: %s" % certificate_path) if not renew: # Don't do anything if renewal wasn't requested explicitly return + else: + cert = None # If machine is joined to domain attempt to present machine credentials for authentication if system_keytab_required: @@ -211,7 +212,7 @@ def certidude_request_certificate(server, system_keytab_required, key_path, requ "Accept": "application/x-x509-user-cert,application/x-pem-file" } - if renew: + if renew and cert: signer = key.signer( padding.PSS( mgf=padding.MGF1(hashes.SHA512()), @@ -233,7 +234,7 @@ def certidude_request_certificate(server, system_keytab_required, key_path, requ if submission.status_code == requests.codes.ok: pass if submission.status_code == requests.codes.accepted: - # Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now + click.echo("Server accepted the request, but refused to sign immideately (%s). Waiting was not requested, hence quitting for now" % submission.text) return if submission.status_code == requests.codes.conflict: raise errors.DuplicateCommonNameError("Different signing request with same CN is already present on server, server refuses to overwrite") diff --git a/certidude/templates/mail/token.md b/certidude/templates/mail/token.md index fdb077a..c3cfeba 100644 --- a/certidude/templates/mail/token.md +++ b/certidude/templates/mail/token.md @@ -1,7 +1,11 @@ Token for {{ user.name }} +{% if issuer == user %} +Token has been issued for {{ user }} for retrieving profile from link below. +{% else %} {{ issuer }} has provided {{ user }} a token for retrieving profile from the link below. +{% endif %} {% if config.BUNDLE_FORMAT == "ovpn" %} To set up OpenVPN for your device: diff --git a/certidude/templates/nginx-https-site.conf b/certidude/templates/nginx-https-site.conf index c64f8ee..e700b2b 100644 --- a/certidude/templates/nginx-https-site.conf +++ b/certidude/templates/nginx-https-site.conf @@ -1,8 +1,8 @@ server { listen 80; - server_name {{const.FQDN}}; - rewrite ^ https://{{const.FQDN}}$request_uri?; + server_name {{common_name}}; + rewrite ^ https://{{common_name}}$request_uri?; } server { @@ -10,7 +10,7 @@ server { add_header X-Frame-Options "DENY"; add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; listen 443 ssl; - server_name {{const.FQDN}}; + server_name {{common_name}}; client_max_body_size 10G; ssl_certificate {{certificate_path}}; ssl_certificate_key {{key_path}}; diff --git a/certidude/templates/server/server.conf b/certidude/templates/server/server.conf index 6bfe0f5..53cf473 100644 --- a/certidude/templates/server/server.conf +++ b/certidude/templates/server/server.conf @@ -88,6 +88,7 @@ revoked url = {{ revoked_url }} renewal allowed = false ;renewal allowed = true + [push] # This should occasionally be regenerated event source token = {{ push_token }} diff --git a/tests/test_cli.py b/tests/test_cli.py index 4c219a7..5dd6976 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,6 +55,21 @@ def generate_csr(cn=None): ).public_bytes(serialization.Encoding.PEM) return buf + +def clean_client(): + assert os.getuid() == 0 and os.getgid() == 0 + if os.path.exists("/etc/certidude/client.conf"): + os.unlink("/etc/certidude/client.conf") + if os.path.exists("/etc/certidude/services.conf"): + os.unlink("/etc/certidude/services.conf") + + # Remove client storage area + if os.path.exists("/tmp/ca.example.lan"): + for filename in os.listdir("/tmp/ca.example.lan"): + if filename.endswith(".pem"): + os.unlink(os.path.join("/tmp/ca.example.lan", filename)) + + def test_cli_setup_authority(): import os import sys @@ -78,13 +93,19 @@ def test_cli_setup_authority(): shutil.rmtree("/var/lib/certidude/ca.example.lan") if os.path.exists("/etc/certidude/server.conf"): os.unlink("/etc/certidude/server.conf") - if os.path.exists("/etc/certidude/client.conf"): - os.unlink("/etc/certidude/client.conf") if os.path.exists("/run/certidude"): shutil.rmtree("/run/certidude") if os.path.exists("/var/log/certidude.log"): os.unlink("/var/log/certidude.log") + # Remove nginx stuff + if os.path.exists("/etc/nginx/sites-available/ca.conf"): + os.unlink("/etc/nginx/sites-available/ca.conf") + if os.path.exists("/etc/nginx/sites-enabled/ca.conf"): + os.unlink("/etc/nginx/sites-enabled/ca.conf") + if os.path.exists("/etc/nginx/conf.d/tls.conf"): + os.unlink("/etc/nginx/conf.d/tls.conf") + with open("/etc/ipsec.conf", "w") as fh: # TODO: make compatible with Fedora pass @@ -96,6 +117,8 @@ def test_cli_setup_authority(): if os.path.exists("/etc/openvpn/keys"): shutil.rmtree("/etc/openvpn/keys") + clean_client() + from certidude.cli import entry_point as cli from certidude import const @@ -397,34 +420,62 @@ def test_cli_setup_authority(): assert r2.headers.get('content-type') == "application/x-pkcs12" assert "Signed " in inbox.pop(), inbox - result = runner.invoke(cli, ['setup', 'openvpn', 'server', "-cn", "vpn.example.lan", "ca.example.lan"]) - assert not result.exception, result.output + # Beyond this point don't use client() + const.STORAGE_PATH = "/tmp/" - result = runner.invoke(cli, ['setup', 'openvpn', 'client', "-cn", "roadwarrior1", "ca.example.lan", "vpn.example.lan"]) - assert not result.exception, result.output + ############# + ### nginx ### + ############# + clean_client() - result = runner.invoke(cli, ['setup', 'strongswan', 'server', "-cn", "ipsec.example.lan", "ca.example.lan"]) - assert not result.exception, result.output - - result = runner.invoke(cli, ['setup', 'strongswan', 'client', "-cn", "roadwarrior2", "ca.example.lan", "ipsec.example.lan"]) - assert not result.exception, result.output - - result = runner.invoke(cli, ['setup', 'openvpn', 'networkmanager', "-cn", "roadwarrior3", "ca.example.lan", "vpn.example.lan"]) - assert not result.exception, result.output - - result = runner.invoke(cli, ['setup', 'strongswan', 'networkmanager', "-cn", "roadwarrior4", "ca.example.lan", "ipsec.example.lan"]) + result = runner.invoke(cli, ["setup", "nginx", "-cn", "www.example.lan", "ca.example.lan"]) assert not result.exception, result.output import os - if not os.path.exists("/etc/openvpn/keys"): - os.makedirs("/etc/openvpn/keys") with open("/etc/certidude/client.conf", "a") as fh: fh.write("insecure = true\n") - # pregen dhparam result = runner.invoke(cli, ["request", "--no-wait"]) - assert not result.exception, "server responded %s, server logs say %s" % (result.output, open("/var/log/certidude.log").read()) + assert not result.exception, result.output + + child_pid = os.fork() + if not child_pid: + result = runner.invoke(cli, ['sign', 'www.example.lan']) + assert not result.exception, result.output + return + else: + os.waitpid(child_pid, 0) + + result = runner.invoke(cli, ["request", "--no-wait"]) + assert not result.exception, result.output + assert "Writing certificate to:" in result.output, result.output + + result = runner.invoke(cli, ["request", "--renew", "--no-wait"]) + assert not result.exception, result.output + assert "Writing certificate to:" in result.output, result.output + + # Test nginx setup + assert os.system("nginx -t") == 0, "Generated nginx config was invalid" + + + ############### + ### OpenVPN ### + ############### + + clean_client() + + if not os.path.exists("/etc/openvpn/keys"): + os.makedirs("/etc/openvpn/keys") + + result = runner.invoke(cli, ['setup', 'openvpn', 'server', "-cn", "vpn.example.lan", "ca.example.lan"]) + assert not result.exception, result.output + + with open("/etc/certidude/client.conf", "a") as fh: + fh.write("insecure = true\n") + + result = runner.invoke(cli, ["request", "--no-wait"]) + assert not result.exception, result.output child_pid = os.fork() if not child_pid: @@ -436,13 +487,87 @@ def test_cli_setup_authority(): result = runner.invoke(cli, ["request", "--no-wait"]) assert not result.exception, result.output - result = runner.invoke(cli, ["request", "--renew"]) + assert "Writing certificate to:" in result.output, result.output + assert os.path.exists("/tmp/ca.example.lan/server_cert.pem") + + # Reset config + os.unlink("/etc/certidude/client.conf") + os.unlink("/etc/certidude/services.conf") + + result = runner.invoke(cli, ['setup', 'openvpn', 'client', "-cn", "roadwarrior1", "ca.example.lan", "vpn.example.lan"]) assert not result.exception, result.output + with open("/etc/certidude/client.conf", "a") as fh: + fh.write("insecure = true\n") + + result = runner.invoke(cli, ["request", "--no-wait"]) + assert not result.exception, result.output + assert "Writing certificate to:" in result.output, result.output + + # TODO: test client verification with curl + + ############### + ### IPSec ### + ############### + + clean_client() + + result = runner.invoke(cli, ['setup', 'strongswan', 'server', "-cn", "ipsec.example.lan", "ca.example.lan"]) + assert not result.exception, result.output + + with open("/etc/certidude/client.conf", "a") as fh: + fh.write("insecure = true\n") + + result = runner.invoke(cli, ["request", "--no-wait"]) + assert not result.exception, result.output + + child_pid = os.fork() + if not child_pid: + result = runner.invoke(cli, ['sign', 'ipsec.example.lan']) + assert not result.exception, result.output + return + else: + os.waitpid(child_pid, 0) + + result = runner.invoke(cli, ["request", "--no-wait"]) + assert not result.exception, result.output + assert "Writing certificate to:" in result.output, result.output + assert os.path.exists("/tmp/ca.example.lan/server_cert.pem") + + # Reset config + os.unlink("/etc/certidude/client.conf") + os.unlink("/etc/certidude/services.conf") + + result = runner.invoke(cli, ['setup', 'strongswan', 'client', "-cn", "roadwarrior2", "ca.example.lan", "ipsec.example.lan"]) + assert not result.exception, result.output + + with open("/etc/certidude/client.conf", "a") as fh: + fh.write("insecure = true\n") + + result = runner.invoke(cli, ["request", "--no-wait"]) + assert not result.exception, result.output + assert "Writing certificate to:" in result.output, result.output + + + ###################### + ### NetworkManager ### + ###################### + + result = runner.invoke(cli, ['setup', 'openvpn', 'networkmanager', "-cn", "roadwarrior3", "ca.example.lan", "vpn.example.lan"]) + assert not result.exception, result.output + + result = runner.invoke(cli, ['setup', 'strongswan', 'networkmanager', "-cn", "roadwarrior4", "ca.example.lan", "ipsec.example.lan"]) + assert not result.exception, result.output + + + ################### + ### Final tests ### + ################### + # Test revocation on command-line child_pid = os.fork() if not child_pid: - result = runner.invoke(cli, ['revoke', 'vpn.example.lan']) + result = runner.invoke(cli, ['revoke', 'www.example.lan']) assert not result.exception, result.output return else: