324 lines
11 KiB
Python
324 lines
11 KiB
Python
"""
|
|
.. module: lemur.plugins.lemur_kubernetes.plugin
|
|
:platform: Unix
|
|
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
|
|
:license: Apache, see LICENSE for more details.
|
|
|
|
|
|
The plugin inserts certificates and the private key as Kubernetes secret that
|
|
can later be used to secure service endpoints running in Kubernetes pods
|
|
|
|
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
|
|
"""
|
|
import base64
|
|
import itertools
|
|
import os
|
|
|
|
import requests
|
|
from flask import current_app
|
|
|
|
from lemur.common.defaults import common_name
|
|
from lemur.common.utils import parse_certificate
|
|
from lemur.plugins.bases import DestinationPlugin
|
|
|
|
DEFAULT_API_VERSION = "v1"
|
|
|
|
|
|
def ensure_resource(k8s_api, k8s_base_uri, namespace, kind, name, data):
|
|
# _resolve_uri(k8s_base_uri, namespace, kind, name, api_ver=DEFAULT_API_VERSION)
|
|
url = _resolve_uri(k8s_base_uri, namespace, kind)
|
|
current_app.logger.debug("K8S POST request URL: %s", url)
|
|
|
|
create_resp = k8s_api.post(url, json=data)
|
|
current_app.logger.debug("K8S POST response: %s", create_resp)
|
|
|
|
if 200 <= create_resp.status_code <= 299:
|
|
return None
|
|
elif create_resp.json().get("reason", "") != "AlreadyExists":
|
|
return create_resp.content
|
|
|
|
url = _resolve_uri(k8s_base_uri, namespace, kind, name)
|
|
current_app.logger.debug("K8S PUT request URL: %s", url)
|
|
|
|
update_resp = k8s_api.put(url, json=data)
|
|
current_app.logger.debug("K8S PUT response: %s", update_resp)
|
|
|
|
if not 200 <= update_resp.status_code <= 299:
|
|
return update_resp.content
|
|
|
|
return
|
|
|
|
|
|
def _resolve_ns(k8s_base_uri, namespace, api_ver=DEFAULT_API_VERSION):
|
|
api_group = "api"
|
|
if "/" in api_ver:
|
|
api_group = "apis"
|
|
return "{base}/{api_group}/{api_ver}/namespaces".format(
|
|
base=k8s_base_uri, api_group=api_group, api_ver=api_ver
|
|
) + ("/" + namespace if namespace else "")
|
|
|
|
|
|
def _resolve_uri(k8s_base_uri, namespace, kind, name=None, api_ver=DEFAULT_API_VERSION):
|
|
if not namespace:
|
|
namespace = "default"
|
|
|
|
return "/".join(
|
|
itertools.chain.from_iterable(
|
|
[
|
|
(_resolve_ns(k8s_base_uri, namespace, api_ver=api_ver),),
|
|
((kind + "s").lower(),),
|
|
(name,) if name else (),
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
# Performs Base64 encoding of string to string using the base64.b64encode() function
|
|
# which encodes bytes to bytes.
|
|
def base64encode(string):
|
|
return base64.b64encode(string.encode()).decode()
|
|
|
|
|
|
def build_secret(secret_format, secret_name, body, private_key, cert_chain):
|
|
secret = {
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"type": "Opaque",
|
|
"metadata": {"name": secret_name},
|
|
}
|
|
if secret_format == "Full":
|
|
secret["data"] = {
|
|
"combined.pem": base64encode("%s\n%s" % (body, private_key)),
|
|
"ca.crt": base64encode(cert_chain),
|
|
"service.key": base64encode(private_key),
|
|
"service.crt": base64encode(body),
|
|
}
|
|
if secret_format == "TLS":
|
|
secret["type"] = "kubernetes.io/tls"
|
|
secret["data"] = {
|
|
"tls.crt": base64encode("%s\n%s" % (body, cert_chain)),
|
|
"tls.key": base64encode(private_key),
|
|
}
|
|
if secret_format == "Certificate":
|
|
secret["data"] = {"tls.crt": base64encode(cert_chain)}
|
|
return secret
|
|
|
|
|
|
class KubernetesDestinationPlugin(DestinationPlugin):
|
|
title = "Kubernetes"
|
|
slug = "kubernetes-destination"
|
|
description = "Allow the uploading of certificates to Kubernetes as secret"
|
|
|
|
author = "Mikhail Khodorovskiy"
|
|
author_url = "https://github.com/mik373/lemur"
|
|
|
|
options = [
|
|
{
|
|
"name": "secretNameFormat",
|
|
"type": "str",
|
|
"required": False,
|
|
# Validation is difficult. This regex is used by kubectl to validate secret names:
|
|
# [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*
|
|
# Allowing the insertion of "{common_name}" (or any other such placeholder}
|
|
# at any point in the string proved very challenging and had a tendency to
|
|
# cause my browser to hang. The specified expression will allow any valid string
|
|
# but will also accept many invalid strings.
|
|
"validation": "(?:[a-z0-9.-]|\\{common_name\\})+",
|
|
"helpMessage": 'Must be a valid secret name, possibly including "{common_name}"',
|
|
"default": "{common_name}",
|
|
},
|
|
{
|
|
"name": "kubernetesURL",
|
|
"type": "str",
|
|
"required": False,
|
|
"validation": "https?://[a-zA-Z0-9.-]+(?::[0-9]+)?",
|
|
"helpMessage": "Must be a valid Kubernetes server URL!",
|
|
"default": "https://kubernetes.default",
|
|
},
|
|
{
|
|
"name": "kubernetesAuthToken",
|
|
"type": "str",
|
|
"required": False,
|
|
"validation": "[0-9a-zA-Z-_.]+",
|
|
"helpMessage": "Must be a valid Kubernetes server Token!",
|
|
},
|
|
{
|
|
"name": "kubernetesAuthTokenFile",
|
|
"type": "str",
|
|
"required": False,
|
|
"validation": "(/[^/]+)+",
|
|
"helpMessage": "Must be a valid file path!",
|
|
"default": "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
|
},
|
|
{
|
|
"name": "kubernetesServerCertificate",
|
|
"type": "textarea",
|
|
"required": False,
|
|
"validation": "-----BEGIN CERTIFICATE-----[a-zA-Z0-9/+\\s\\r\\n]+-----END CERTIFICATE-----",
|
|
"helpMessage": "Must be a valid Kubernetes server Certificate!",
|
|
},
|
|
{
|
|
"name": "kubernetesServerCertificateFile",
|
|
"type": "str",
|
|
"required": False,
|
|
"validation": "(/[^/]+)+",
|
|
"helpMessage": "Must be a valid file path!",
|
|
"default": "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
|
|
},
|
|
{
|
|
"name": "kubernetesNamespace",
|
|
"type": "str",
|
|
"required": False,
|
|
"validation": "[a-z0-9]([-a-z0-9]*[a-z0-9])?",
|
|
"helpMessage": "Must be a valid Kubernetes Namespace!",
|
|
},
|
|
{
|
|
"name": "kubernetesNamespaceFile",
|
|
"type": "str",
|
|
"required": False,
|
|
"validation": "(/[^/]+)+",
|
|
"helpMessage": "Must be a valid file path!",
|
|
"default": "/var/run/secrets/kubernetes.io/serviceaccount/namespace",
|
|
},
|
|
{
|
|
"name": "secretFormat",
|
|
"type": "select",
|
|
"required": True,
|
|
"available": ["Full", "TLS", "Certificate"],
|
|
"helpMessage": "The type of Secret to create.",
|
|
"default": "Full",
|
|
},
|
|
]
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(KubernetesDestinationPlugin, self).__init__(*args, **kwargs)
|
|
|
|
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
|
|
|
|
try:
|
|
k8_base_uri = self.get_option("kubernetesURL", options)
|
|
secret_format = self.get_option("secretFormat", options)
|
|
k8s_api = K8sSession(self.k8s_bearer(options), self.k8s_cert(options))
|
|
cn = common_name(parse_certificate(body))
|
|
secret_name_format = self.get_option("secretNameFormat", options)
|
|
secret_name = secret_name_format.format(common_name=cn)
|
|
secret = build_secret(
|
|
secret_format, secret_name, body, private_key, cert_chain
|
|
)
|
|
err = ensure_resource(
|
|
k8s_api,
|
|
k8s_base_uri=k8_base_uri,
|
|
namespace=self.k8s_namespace(options),
|
|
kind="secret",
|
|
name=secret_name,
|
|
data=secret,
|
|
)
|
|
|
|
except Exception as e:
|
|
current_app.logger.exception(
|
|
"Exception in upload: {}".format(e), exc_info=True
|
|
)
|
|
raise
|
|
|
|
if err is not None:
|
|
current_app.logger.error("Error deploying resource: %s", err)
|
|
raise Exception("Error uploading secret: " + err)
|
|
|
|
def k8s_bearer(self, options):
|
|
bearer = self.get_option("kubernetesAuthToken", options)
|
|
if not bearer:
|
|
bearer_file = self.get_option("kubernetesAuthTokenFile", options)
|
|
with open(bearer_file, "r") as file:
|
|
bearer = file.readline()
|
|
if bearer:
|
|
current_app.logger.debug("Using token read from %s", bearer_file)
|
|
else:
|
|
raise Exception(
|
|
"Unable to locate token in options or from %s", bearer_file
|
|
)
|
|
else:
|
|
current_app.logger.debug("Using token from options")
|
|
return bearer
|
|
|
|
def k8s_cert(self, options):
|
|
cert_file = self.get_option("kubernetesServerCertificateFile", options)
|
|
cert = self.get_option("kubernetesServerCertificate", options)
|
|
if cert:
|
|
cert_file = os.path.join(
|
|
os.path.abspath(os.path.dirname(__file__)), "k8.cert"
|
|
)
|
|
with open(cert_file, "w") as text_file:
|
|
text_file.write(cert)
|
|
current_app.logger.debug("Using certificate from options")
|
|
else:
|
|
current_app.logger.debug("Using certificate from %s", cert_file)
|
|
return cert_file
|
|
|
|
def k8s_namespace(self, options):
|
|
namespace = self.get_option("kubernetesNamespace", options)
|
|
if not namespace:
|
|
namespace_file = self.get_option("kubernetesNamespaceFile", options)
|
|
with open(namespace_file, "r") as file:
|
|
namespace = file.readline()
|
|
if namespace:
|
|
current_app.logger.debug(
|
|
"Using namespace %s from %s", namespace, namespace_file
|
|
)
|
|
else:
|
|
raise Exception(
|
|
"Unable to locate namespace in options or from %s", namespace_file
|
|
)
|
|
else:
|
|
current_app.logger.debug("Using namespace %s from options", namespace)
|
|
return namespace
|
|
|
|
|
|
class K8sSession(requests.Session):
|
|
def __init__(self, bearer, cert_file):
|
|
super(K8sSession, self).__init__()
|
|
|
|
self.headers.update({"Authorization": "Bearer %s" % bearer})
|
|
|
|
self.verify = cert_file
|
|
|
|
def request(
|
|
self,
|
|
method,
|
|
url,
|
|
params=None,
|
|
data=None,
|
|
headers=None,
|
|
cookies=None,
|
|
files=None,
|
|
auth=None,
|
|
timeout=30,
|
|
allow_redirects=True,
|
|
proxies=None,
|
|
hooks=None,
|
|
stream=None,
|
|
verify=None,
|
|
cert=None,
|
|
json=None,
|
|
):
|
|
"""
|
|
This method overrides the default timeout to be 10s.
|
|
"""
|
|
return super(K8sSession, self).request(
|
|
method,
|
|
url,
|
|
params,
|
|
data,
|
|
headers,
|
|
cookies,
|
|
files,
|
|
auth,
|
|
timeout,
|
|
allow_redirects,
|
|
proxies,
|
|
hooks,
|
|
stream,
|
|
verify,
|
|
cert,
|
|
json,
|
|
)
|