mirror of
https://github.com/laurivosandi/certidude
synced 2024-12-23 00:25:18 +00:00
Refactor signature request submission
Certidude client now reads configuration from /etc/certidude/client.conf, submits CSR-s and once signed configures services based on /etc/certidude/services.conf
This commit is contained in:
parent
d8abde3d53
commit
f2df17bb88
@ -8,6 +8,7 @@ from OpenSSL import crypto
|
||||
from certidude import config, push
|
||||
from certidude.wrappers import Certificate, Request
|
||||
from certidude.signer import raw_sign
|
||||
from certidude import errors
|
||||
|
||||
RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$"
|
||||
|
||||
@ -15,12 +16,6 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0
|
||||
# https://jamielinux.com/docs/openssl-certificate-authority/
|
||||
# http://pycopia.googlecode.com/svn/trunk/net/pycopia/ssl/certs.py
|
||||
|
||||
class RequestExists(Exception):
|
||||
pass
|
||||
|
||||
class DuplicateCommonNameError(Exception):
|
||||
pass
|
||||
|
||||
def publish_certificate(func):
|
||||
# TODO: Implement e-mail and nginx notifications using hooks
|
||||
def wrapped(csr, *args, **kwargs):
|
||||
@ -68,9 +63,9 @@ def store_request(buf, overwrite=False):
|
||||
# If there is cert, check if it's the same
|
||||
if os.path.exists(request_path):
|
||||
if open(request_path).read() == buf:
|
||||
raise RequestExists("Request already exists")
|
||||
raise errors.RequestExists("Request already exists")
|
||||
else:
|
||||
raise DuplicateCommonNameError("Another request with same common name already exists")
|
||||
raise errors.DuplicateCommonNameError("Another request with same common name already exists")
|
||||
else:
|
||||
with open(request_path + ".part", "w") as fh:
|
||||
fh.write(buf)
|
||||
|
179
certidude/cli.py
179
certidude/cli.py
@ -9,6 +9,7 @@ import logging
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import requests
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
@ -23,6 +24,7 @@ from time import sleep
|
||||
from setproctitle import setproctitle
|
||||
from OpenSSL import crypto
|
||||
|
||||
|
||||
env = Environment(loader=PackageLoader("certidude", "templates"), trim_blocks=True)
|
||||
|
||||
# Big fat warning:
|
||||
@ -60,12 +62,162 @@ if os.getuid() >= 1000:
|
||||
FIRST_NAME = gecos
|
||||
|
||||
|
||||
@click.command("spawn", help="Run privilege isolated signer process")
|
||||
@click.command("request", help="Run processes for requesting certificates and configuring services")
|
||||
@click.option("-f", "--fork", default=False, is_flag=True, help="Fork to background")
|
||||
def certidude_spawn_request(fork):
|
||||
from certidude.helpers import certidude_request_certificate
|
||||
from configparser import ConfigParser
|
||||
|
||||
clients = ConfigParser()
|
||||
clients.readfp(open("/etc/certidude/client.conf"))
|
||||
|
||||
services = ConfigParser()
|
||||
services.readfp(open("/etc/certidude/services.conf"))
|
||||
|
||||
# Process directories
|
||||
run_dir = "/run/certidude"
|
||||
|
||||
# Prepare signer PID-s directory
|
||||
if not os.path.exists(run_dir):
|
||||
click.echo("Creating: %s" % run_dir)
|
||||
os.makedirs(run_dir)
|
||||
|
||||
for certificate in clients.sections():
|
||||
if clients.get(certificate, "managed") != "true":
|
||||
continue
|
||||
|
||||
pid_path = os.path.join(run_dir, certificate + ".pid")
|
||||
|
||||
try:
|
||||
with open(pid_path) as fh:
|
||||
pid = int(fh.readline())
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
click.echo("Terminated process %d" % pid)
|
||||
os.unlink(pid_path)
|
||||
except (ValueError, ProcessLookupError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
if fork:
|
||||
child_pid = os.fork()
|
||||
else:
|
||||
child_pid = None
|
||||
|
||||
if child_pid:
|
||||
click.echo("Spawned certificate request process with PID %d" % (child_pid))
|
||||
continue
|
||||
|
||||
with open(pid_path, "w") as fh:
|
||||
fh.write("%d\n" % os.getpid())
|
||||
setproctitle("certidude spawn request %s" % certificate)
|
||||
retries = 30
|
||||
while retries > 0:
|
||||
try:
|
||||
certidude_request_certificate(
|
||||
clients.get(certificate, "server"),
|
||||
clients.get(certificate, "key_path"),
|
||||
clients.get(certificate, "request_path"),
|
||||
clients.get(certificate, "certificate_path"),
|
||||
clients.get(certificate, "authority_path"),
|
||||
socket.gethostname(),
|
||||
None,
|
||||
autosign=True,
|
||||
wait=True)
|
||||
break
|
||||
except requests.exceptions.Timeout:
|
||||
retries -= 1
|
||||
continue
|
||||
|
||||
for endpoint in services.sections():
|
||||
if services.get(endpoint, "certificate") != certificate:
|
||||
continue
|
||||
|
||||
csummer = hashlib.sha1()
|
||||
csummer.update(endpoint.encode("ascii"))
|
||||
csum = csummer.hexdigest()
|
||||
uuid = csum[:8] + "-" + csum[8:12] + "-" + csum[12:16] + "-" + csum[16:20] + "-" + csum[20:32]
|
||||
|
||||
# Set up IPsec via NetworkManager
|
||||
if services.get(endpoint, "service") == "network-manager/strongswan":
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.add_section("connection")
|
||||
config.add_section("vpn")
|
||||
config.add_section("ipv4")
|
||||
|
||||
config.set("connection", "id", endpoint)
|
||||
config.set("connection", "uuid", uuid)
|
||||
config.set("connection", "type", "vpn")
|
||||
|
||||
config.set("vpn", "service-type", "org.freedesktop.NetworkManager.strongswan")
|
||||
config.set("vpn", "userkey", clients.get(certificate, "key_path"))
|
||||
config.set("vpn", "usercert", clients.get(certificate, "certificate_path"))
|
||||
config.set("vpn", "encap", "no")
|
||||
config.set("vpn", "address", services.get(endpoint, "remote"))
|
||||
config.set("vpn", "virtual", "yes")
|
||||
config.set("vpn", "method", "key")
|
||||
config.set("vpn", "certificate", clients.get(certificate, "authority_path"))
|
||||
config.set("vpn", "ipcomp", "no")
|
||||
|
||||
config.set("ipv4", "method", "auto")
|
||||
|
||||
# Add routes, may need some more tweaking
|
||||
for index, subnet in enumerate(services.get(endpoint, "route").split(","), start=1):
|
||||
config.set("ipv4", "route%d" % index, subnet)
|
||||
|
||||
# Prevent creation of files with liberal permissions
|
||||
os.umask(0o177)
|
||||
|
||||
# Write keyfile
|
||||
with open(os.path.join("/etc/NetworkManager/system-connections", endpoint), "w") as configfile:
|
||||
config.write(configfile)
|
||||
continue
|
||||
|
||||
# Set up IPsec via /etc/ipsec.conf
|
||||
if services.get(endpoint, "service") == "strongswan":
|
||||
from ipsecparse import loads
|
||||
config = loads(open('/etc/ipsec.conf').read())
|
||||
config["conn", endpoint] = dict(
|
||||
leftsourceip="%config",
|
||||
left="%defaultroute",
|
||||
leftcert=clients.get(certificate, "certificate_path"),
|
||||
rightid="%any",
|
||||
right=services.get(endpoint, "remote"),
|
||||
rightsubnet=services.get(endpoint, "route"),
|
||||
keyexchange="ikev2",
|
||||
keyingtries="300",
|
||||
dpdaction="restart",
|
||||
closeaction="restart",
|
||||
auto="start")
|
||||
with open("/etc/ipsec.conf.part", "w") as fh:
|
||||
fh.write(config.dumps())
|
||||
os.rename("/etc/ipsec.conf.part", "/etc/ipsec.conf")
|
||||
|
||||
# Regenerate /etc/ipsec.secrets
|
||||
with open("/etc/ipsec.secrets.part", "w") as fh:
|
||||
for filename in os.listdir("/etc/ipsec.d/private"):
|
||||
if not filename.endswith(".pem"):
|
||||
continue
|
||||
fh.write(": RSA /etc/ipsec.d/private/%s\n" % filename)
|
||||
os.rename("/etc/ipsec.secrets.part", "/etc/ipsec.secrets")
|
||||
|
||||
# Attempt to reload config or start if it's not running
|
||||
if os.system("ipsec update") == 130:
|
||||
os.system("ipsec start")
|
||||
continue
|
||||
|
||||
|
||||
|
||||
# TODO: OpenVPN, Puppet, OpenLDAP, intranet HTTPS, <insert awesomeness here>
|
||||
|
||||
os.unlink(pid_path)
|
||||
|
||||
|
||||
@click.command("signer", help="Run privilege isolated signer process")
|
||||
@click.option("-k", "--kill", default=False, is_flag=True, help="Kill previous instance")
|
||||
@click.option("-n", "--no-interaction", default=True, is_flag=True, help="Don't load password protected keys")
|
||||
def certidude_spawn(kill, no_interaction):
|
||||
def certidude_spawn_signer(kill, no_interaction):
|
||||
"""
|
||||
Spawn processes for signers
|
||||
Spawn privilege isolated signer process
|
||||
"""
|
||||
from certidude import config
|
||||
|
||||
@ -80,7 +232,6 @@ def certidude_spawn(kill, no_interaction):
|
||||
|
||||
# Process directories
|
||||
run_dir = "/run/certidude"
|
||||
chroot_dir = os.path.join(run_dir, "jail")
|
||||
|
||||
# Prepare signer PID-s directory
|
||||
if not os.path.exists(run_dir):
|
||||
@ -92,15 +243,13 @@ def certidude_spawn(kill, no_interaction):
|
||||
"".encode("charmap")
|
||||
|
||||
# Prepare chroot directories
|
||||
chroot_dir = os.path.join(run_dir, "jail")
|
||||
if not os.path.exists(os.path.join(chroot_dir, "dev")):
|
||||
os.makedirs(os.path.join(chroot_dir, "dev"))
|
||||
if not os.path.exists(os.path.join(chroot_dir, "dev", "urandom")):
|
||||
# TODO: use os.mknod instead
|
||||
os.system("mknod -m 444 %s c 1 9" % os.path.join(chroot_dir, "dev", "urandom"))
|
||||
|
||||
ca_loaded = False
|
||||
|
||||
|
||||
try:
|
||||
with open(config.SIGNER_PID_PATH) as fh:
|
||||
pid = int(fh.readline())
|
||||
@ -122,11 +271,13 @@ def certidude_spawn(kill, no_interaction):
|
||||
|
||||
child_pid = os.fork()
|
||||
|
||||
if child_pid == 0:
|
||||
if child_pid:
|
||||
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH))
|
||||
return
|
||||
|
||||
setproctitle("certidude spawn signer" % section)
|
||||
with open(config.SIGNER_PID_PATH, "w") as fh:
|
||||
fh.write("%d\n" % os.getpid())
|
||||
|
||||
# setproctitle("%s spawn %s" % (sys.argv[0], ca.common_name))
|
||||
logging.basicConfig(
|
||||
filename="/var/log/signer.log",
|
||||
level=logging.INFO)
|
||||
@ -140,8 +291,7 @@ def certidude_spawn(kill, no_interaction):
|
||||
config.CERTIFICATE_EXTENDED_KEY_USAGE_FLAGS,
|
||||
config.REVOCATION_LIST_LIFETIME)
|
||||
asyncore.loop()
|
||||
else:
|
||||
click.echo("Spawned certidude signer process with PID %d at %s" % (child_pid, config.SIGNER_SOCKET_PATH))
|
||||
|
||||
|
||||
|
||||
@click.command("client", help="Setup X.509 certificates for application")
|
||||
@ -894,6 +1044,9 @@ def certidude_setup_openvpn(): pass
|
||||
@click.group("setup", help="Getting started section")
|
||||
def certidude_setup(): pass
|
||||
|
||||
@click.group("spawn", help="Spawn helper processes")
|
||||
def certidude_spawn(): pass
|
||||
|
||||
@click.group()
|
||||
def entry_point(): pass
|
||||
|
||||
@ -907,6 +1060,8 @@ certidude_setup.add_command(certidude_setup_openvpn)
|
||||
certidude_setup.add_command(certidude_setup_strongswan)
|
||||
certidude_setup.add_command(certidude_setup_client)
|
||||
certidude_setup.add_command(certidude_setup_production)
|
||||
certidude_spawn.add_command(certidude_spawn_request)
|
||||
certidude_spawn.add_command(certidude_spawn_signer)
|
||||
entry_point.add_command(certidude_setup)
|
||||
entry_point.add_command(certidude_serve)
|
||||
entry_point.add_command(certidude_spawn)
|
||||
|
12
certidude/errors.py
Normal file
12
certidude/errors.py
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
class RequestExists(Exception):
|
||||
pass
|
||||
|
||||
class FatalError(Exception):
|
||||
"""
|
||||
Exception to be raised when user intervention is required
|
||||
"""
|
||||
pass
|
||||
|
||||
class DuplicateCommonNameError(FatalError):
|
||||
pass
|
@ -1,11 +1,13 @@
|
||||
|
||||
import click
|
||||
import os
|
||||
import requests
|
||||
import urllib.request
|
||||
from certidude import errors
|
||||
from certidude.wrappers import Certificate, Request
|
||||
from OpenSSL import crypto
|
||||
|
||||
def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None):
|
||||
def certidude_request_certificate(url, key_path, request_path, certificate_path, authority_path, common_name, org_unit=None, email_address=None, given_name=None, surname=None, autosign=False, wait=False, key_usage=None, extended_key_usage=None, ip_address=None, dns=None):
|
||||
"""
|
||||
Exchange CSR for certificate using Certidude HTTP API server
|
||||
"""
|
||||
@ -18,12 +20,10 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path,
|
||||
request_params.add("wait=forever")
|
||||
|
||||
# Expand ca.example.com to http://ca.example.com/api/
|
||||
if not "/" in url:
|
||||
if not url.endswith("/"):
|
||||
url += "/api/"
|
||||
if "//" not in url:
|
||||
url = "http://" + url
|
||||
if not url.endswith("/"):
|
||||
url = url + "/"
|
||||
|
||||
authority_url = url + "certificate"
|
||||
request_url = url + "request"
|
||||
@ -31,32 +31,26 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path,
|
||||
if request_params:
|
||||
request_url = request_url + "?" + "&".join(request_params)
|
||||
|
||||
if os.path.exists(certificate_path):
|
||||
click.echo("Found certificate: %s" % certificate_path)
|
||||
# TODO: Check certificate validity, download CRL?
|
||||
return
|
||||
|
||||
if os.path.exists(authority_path):
|
||||
click.echo("Found CA certificate in: %s" % authority_path)
|
||||
else:
|
||||
if authority_url:
|
||||
click.echo("Attempting to fetch CA certificate from %s" % authority_url)
|
||||
try:
|
||||
with urllib.request.urlopen(authority_url) as fh:
|
||||
buf = fh.read()
|
||||
try:
|
||||
cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf)
|
||||
except crypto.Error:
|
||||
raise ValueError("Failed to parse PEM: %s" % buf)
|
||||
with open(authority_path + ".part", "wb") as oh:
|
||||
oh.write(buf)
|
||||
click.echo("Writing CA certificate to: %s" % authority_path)
|
||||
os.rename(authority_path + ".part", authority_path)
|
||||
except urllib.error.HTTPError as e:
|
||||
click.echo("Failed to fetch CA certificate, server responded with: %d %s" % (e.code, e.reason), err=True)
|
||||
return 1
|
||||
else:
|
||||
raise FileNotFoundError("CA certificate not found and no URL specified")
|
||||
|
||||
try:
|
||||
certificate = Certificate(open(certificate_path))
|
||||
click.echo("Found certificate: %s" % certificate_path)
|
||||
except FileNotFoundError:
|
||||
r = requests.get(authority_url)
|
||||
cert = crypto.load_certificate(crypto.FILETYPE_PEM, r.text)
|
||||
except crypto.Error:
|
||||
raise ValueError("Failed to parse PEM: %s" % r.text)
|
||||
with open(authority_path + ".part", "w") as oh:
|
||||
oh.write(r.text)
|
||||
click.echo("Writing CA certificate to: %s" % authority_path)
|
||||
os.rename(authority_path + ".part", authority_path)
|
||||
|
||||
try:
|
||||
request = Request(open(request_path))
|
||||
click.echo("Found signing request: %s" % request_path)
|
||||
@ -117,42 +111,33 @@ def certidude_request_certificate(url, key_path, request_path, certificate_path,
|
||||
os.rename(request_path + ".part", request_path)
|
||||
|
||||
|
||||
with open(request_path, "rb") as fh:
|
||||
buf = fh.read()
|
||||
submission = urllib.request.Request(request_url, buf)
|
||||
submission.add_header("User-Agent", "Certidude")
|
||||
submission.add_header("Content-Type", "application/pkcs10")
|
||||
submission.add_header("Accept", "application/x-x509-user-cert")
|
||||
|
||||
click.echo("Submitting to %s, waiting for response..." % request_url)
|
||||
try:
|
||||
response = urllib.request.urlopen(submission)
|
||||
buf = response.read()
|
||||
if response.code == 202:
|
||||
click.echo("No waiting was requested and server responded with 202 Accepted, run this command again once the certificate is signed")
|
||||
return 1
|
||||
assert buf, "Server responded with no body, status code %d" % response.code
|
||||
cert = crypto.load_certificate(crypto.FILETYPE_PEM, buf)
|
||||
except crypto.Error:
|
||||
if buf == b'-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n':
|
||||
submission = requests.post(request_url,
|
||||
data=open(request_path),
|
||||
headers={"User-Agent": "Certidude", "Content-Type": "application/pkcs10", "Accept": "application/x-x509-user-cert"})
|
||||
|
||||
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
|
||||
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")
|
||||
else:
|
||||
submission.raise_for_status()
|
||||
|
||||
if submission.text == '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n':
|
||||
# Should the client retry or disable request submission?
|
||||
raise ValueError("Server refused to sign the request") # TODO: Raise proper exception
|
||||
else:
|
||||
|
||||
try:
|
||||
cert = crypto.load_certificate(crypto.FILETYPE_PEM, submission.text)
|
||||
except crypto.Error:
|
||||
raise ValueError("Failed to parse PEM: %s" % buf)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 409:
|
||||
click.echo("Different signing request with same CN is already present on server, server refuses to overwrite", err=True)
|
||||
return 2
|
||||
else:
|
||||
click.echo("Failed to fetch certificate, server responded with: %d %s" % (e.code, e.reason), err=True)
|
||||
return 3
|
||||
else:
|
||||
if response.code == 202:
|
||||
click.echo("Server stored the request for processing (202 Accepted), but waiting was not requested, hence quitting for now", err=True)
|
||||
return 254
|
||||
|
||||
os.umask(0o022)
|
||||
with open(certificate_path + ".part", "wb") as gh:
|
||||
gh.write(buf)
|
||||
with open(certificate_path + ".part", "w") as fh:
|
||||
fh.write(submission.text)
|
||||
|
||||
click.echo("Writing certificate to: %s" % certificate_path)
|
||||
os.rename(certificate_path + ".part", certificate_path)
|
||||
|
@ -4,6 +4,7 @@ cryptography==1.0
|
||||
falcon==0.3.0
|
||||
humanize==0.5.1
|
||||
idna==2.0
|
||||
ipsecparse==0.1.0
|
||||
Jinja2==2.8
|
||||
ldap3==0.9.8.8
|
||||
MarkupSafe==0.23
|
||||
@ -14,5 +15,6 @@ pycrypto==2.6.1
|
||||
pykerberos==1.1.8
|
||||
pyOpenSSL==0.15.1
|
||||
python-mimeparse==0.1.4
|
||||
requests==2.2.1
|
||||
setproctitle==1.1.9
|
||||
six==1.9.0
|
||||
|
Loading…
Reference in New Issue
Block a user