hscloud/cluster/clustercfg/ca.py
Sergiusz Bazanski 0dcc702c64 cluster: bump nearly-expired certs
This makes clustercfg ensure certificates are valid for at least 30
days, and renew them otherwise.

We use this to bump all the certs that were about to expire in a week.
They are now valid until 2021.

There's still some certs that expire in 2020. We need to figure out a
better story for this, especially as the next expiry is 2021 - todays
prod rollout was somewhat disruptive (basically this was done by a full
cluster upgrade-like rollout flow, via clustercfg).

We also drive-by bump the number of mons in ceph-waw3 to 3, as it shouls
be (this gets rid of a nasty SPOF that would've bitten us during this
upgrade otherwise).

Change-Id: Iee050b1b9cba4222bc0f3c7bce9e4cf9b25c8bdc
2020-03-28 18:01:40 +01:00

315 lines
8.3 KiB
Python

# encoding: utf-8
from datetime import datetime, timezone
import json
import logging
import os
from six import StringIO
import subprocess
import tempfile
logger = logging.getLogger(__name__)
_std_subj = {
"C": "PL",
"ST": "Mazowieckie",
"L": "Warsaw",
"O": "Warsaw Hackerspace",
"OU": "clustercfg",
}
_ca_csr = {
"CN": "Prototype Test Certificate Authority",
"key": {
"algo": "rsa",
"size": 2048
},
"names": [ _std_subj ],
}
_ca_config = {
"signing": {
"default": {
"expiry": "168h"
},
"profiles": {
"intermediate": {
"expiry": "8760h",
"usages": [
"signing",
"key encipherment",
"cert sign",
"crl sign",
"server auth",
"client auth",
],
"ca_constraint": {
"is_ca": True,
},
},
"server": {
"expiry": "8760h",
"usages": [
"signing",
"key encipherment",
"server auth"
]
},
"client": {
"expiry": "8760h",
"usages": [
"signing",
"key encipherment",
"client auth"
]
},
"client-server": {
"expiry": "8760h",
"usages": [
"signing",
"key encipherment",
"server auth",
"client auth"
]
}
}
}
}
class CAException(Exception):
pass
class CA(object):
def __init__(self, secretstore, certdir, short, cn):
self.ss = secretstore
self.cdir = certdir
self.short = short
self.cn = cn
self._init_ca()
def __str__(self):
return 'CN={} ({})'.format(self.cn, self.short)
@property
def _secret_key(self):
return 'ca-{}.key'.format(self.short)
@property
def _cert(self):
return os.path.join(self.cdir, 'ca-{}.crt'.format(self.short))
@property
def cert_data(self):
with open(self._cert) as f:
return f.read()
def _cfssl_call(self, args, obj=None, stdin=None):
p = subprocess.Popen(['cfssl'] + args,
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
if obj is not None:
stdin = json.dumps(obj)
outs, errs = p.communicate(stdin.encode())
if p.returncode != 0:
raise Exception(
'cfssl failed. stderr: %r, stdout: %r, code: %r' % (
errs, outs, p.returncode))
out = json.loads(outs)
return out
def _init_ca(self):
if self.ss.exists(self._secret_key):
return
ca_csr = dict(_ca_csr)
ca_csr['CN'] = self.cn
logger.info("{}: Generating CA...".format(self))
out = self._cfssl_call(['gencert', '-initca', '-'], obj=ca_csr)
f = self.ss.open(self._secret_key, 'w')
f.write(out['key'])
f.close()
f = open(self._cert, 'w')
f.write(out['cert'])
f.close()
def gen_key(self, hosts, o=_std_subj['O'], ou=_std_subj['OU'], save=None):
"""お元気ですか?"""
cfg = {
"CN": hosts[0],
"hosts": hosts,
"key": {
"algo": "rsa",
"size": 4096,
},
"names": [
{
"C": _std_subj["C"],
"ST": _std_subj["ST"],
"L": _std_subj["L"],
"O": o,
"OU": ou,
},
],
}
cfg.update(_ca_config)
logger.info("{}: Generating key/CSR for {}".format(self, hosts))
out = self._cfssl_call(['genkey', '-'], obj=cfg)
key, csr = out['key'], out['csr']
if save is not None:
logging.info("{}: Saving new key to secret {}".format(self, save))
f = self.ss.open(save, 'w')
f.write(key)
f.close()
return key, csr
def gen_csr(self, key, hosts, o=_std_subj['O'], ou=_std_subj['OU']):
"""
Generate a CSR while already having a private key - for renewals, etc.
TODO(q3k): this shouldn't be a CA method, but a cert method.
"""
cfg = {
"CN": hosts[0],
"hosts": hosts,
"key": {
"algo": "rsa",
"size": 4096,
},
"names": [
{
"C": _std_subj["C"],
"ST": _std_subj["ST"],
"L": _std_subj["L"],
"O": o,
"OU": ou,
},
],
}
cfg.update(_ca_config)
logger.info("{}: Generating CSR for {}".format(self, hosts))
out = self._cfssl_call(['gencsr', '-key', key, '-'], obj=cfg)
return out['csr']
def sign(self, csr, save=None, profile='client-server'):
logging.info("{}: Signing CSR".format(self))
ca = self._cert
cakey = self.ss.plaintext(self._secret_key)
config = tempfile.NamedTemporaryFile(mode='w')
json.dump(_ca_config, config)
config.flush()
out = self._cfssl_call(['sign', '-ca=' + ca, '-ca-key=' + cakey,
'-profile='+profile, '-config='+config.name, '-'], stdin=csr)
cert = out['cert']
if save is not None:
name = os.path.join(self.cdir, save)
logging.info("{}: Saving new certificate to {}".format(self, name))
f = open(name, 'w')
f.write(cert)
f.close()
config.close()
return cert
def upload(self, c, remote_cert):
logger.info("Uploading CA {} to {}".format(self, remote_cert))
c.put(local=self._cert, remote=remote_cert)
def make_cert(self, *a, **kw):
return ManagedCertificate(self, *a, **kw)
class ManagedCertificate(object):
def __init__(self, ca, name, hosts, o=None, ou=None, profile='client-server'):
self.ca = ca
self.hosts = hosts
self.name = name
self.key = '{}.key'.format(name)
self.cert = '{}.cert'.format(name)
self.o = o
self.ou = ou
self.profile = profile
self.ensure()
def __str__(self):
return '{}'.format(self.name)
@property
def key_exists(self):
return self.ca.ss.exists(self.key)
@property
def key_data(self):
f = open(self.ca.ss.open(self.key))
d = f.read()
f.close()
return d
@property
def key_path(self):
return self.ca.ss.plaintext(self.key)
@property
def cert_path(self):
return os.path.join(self.ca.cdir, self.cert)
@property
def cert_exists(self):
return os.path.exists(self.cert_path)
@property
def cert_data(self):
with open(self.cert_path) as f:
return f.read()
@property
def cert_expires_soon(self):
if not self.cert_exists:
return False
out = self.ca._cfssl_call(['certinfo', '-cert', self.cert_path], stdin="")
not_after = datetime.strptime(out['not_after'], '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
until = not_after - datetime.now(timezone.utc)
if until.days < 30:
return True
return False
def ensure(self):
if self.key_exists and self.cert_exists and not self.cert_expires_soon:
return
key = None
if not self.key_exists:
logger.info("{}: Generating key...".format(self))
key, csr = self.ca.gen_key(self.hosts, o=self.o, ou=self.ou, save=self.key)
else:
logger.info("{}: Renewing certificate...".format(self))
# Use already existing key
csr = self.ca.gen_csr(self.key_path, self.hosts, o=self.o, ou=self.ou)
self.ca.sign(csr, save=self.cert, profile=self.profile)
def upload(self, c, remote_cert, remote_key, concat_ca=False):
logger.info("Uploading Cert {} to {} & {}".format(self, remote_cert, remote_key))
if concat_ca:
f = StringIO(self.cert_data + self.ca.cert_data)
c.put(local=f, remote=remote_cert)
else:
c.put(local=self.cert_path, remote=remote_cert)
c.put(local=self.key_path, remote=remote_key)
def upload_pki(self, c, pki, concat_ca=False):
self.upload(c, pki['cert'], pki['key'], concat_ca)