diff --git a/.reviewboardrc b/.reviewboardrc deleted file mode 100644 index 5cd5b5c..0000000 --- a/.reviewboardrc +++ /dev/null @@ -1,6 +0,0 @@ -REVIEWBOARD_URL = 'https://rbcommons.com/s/platform9/' -REPOSITORY = 'vouch' -GUESS_DESCRIPTION = True -GUESS_SUMMARY = True -TARGET_GROUPS = 'platform9' -TRACKING_BRANCH = 'origin/master' diff --git a/Makefile b/Makefile index 80e24c7..81d8c08 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,8 @@ stage-with-py-container: dist stage: $(SRCROOT)/run-staging-in-container.sh && \ cp -r $(SRCROOT)/container/* $(STAGE)/ + mkdir $(STAGE)/vouch + cp -r $(SRCROOT)/vouch/* $(STAGE)/vouch/ $(BUILD_DIR): mkdir -p $@ diff --git a/bin/common.py b/bin/common.py deleted file mode 100644 index 468d633..0000000 --- a/bin/common.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/bin/env python - -import logging -import os -import random -import requests -import string -import sys - -from argparse import ArgumentParser -from firkinize.configstore.consul import Consul -from requests import HTTPError -from vaultlib.ca import VaultCA - -logging.basicConfig(level=logging.DEBUG) - -LOG = logging.getLogger(__name__) - -""" -note from tdell: - -Originally there lived a single "vouch" section at the customer level. It was written at a time -when we supported only single regions, and when multi-region support was finally added, it would be -clobbered by subsequent region deployments. This meant hosts could only be onboarded to the -region most recently deployed. - -Now there are "service/vouch" sections at the region level. Though we have a legacy vouch -section at the customer level, please mostly ignore it. - -The ca_signing_role was originally hosts-{customer_name}. But in it we find a policy for a single -region, so we had a choice to either add additional regions to this policy, or create policies -for each region. The latter decision was taken. - -Now each region has its own ca_signing_role as hosts-{region_name}. - -Some old deployments are extant. There is now a fabricate_missing_data() function that creates -a region-level vouch configuration during upgrade. In doing so it might create new vault tokens. - -I did not enjoy untangling this. -""" - -""" -init-region utility for setting up the vouch environment. Expects the following -as a starting point: - -customers//keystone -|-- users -| -customers//vouch -|-- ca_name -|-- ca_common_name -|-- vault -| |-- server_key (usually vault_servers/) -| -vault_servers/ -|-- url -|-- admin_token - -In init-region, we create a keystone user, and a vault role and limited access -token for host certificate signing. After we init, we should see: - -customers//keystone -|-- users -| |-- vouch -| |-- email -| |-- password -| |-- project -| |-- role -| -customers//vouch -|-- ca_name -|-- ca_common_name -|-- ca_signing_role -|-- keystone_user -| |-- email -| |-- password -| |-- project -| |-- role -|-- vault -| |-- server_key (usually vault_servers/) -| |-- url -| |-- host_signing_token -| -vault_servers/ -|-- url -|-- admin_token - -""" - - -def parse_args(): - parser = ArgumentParser(description='Initialize vouch signing service') - parser.add_argument('--config-url', default='http://localhost:8500', - help='Address of the config node, default http://localhost:8500') - parser.add_argument('--customer-id', - help='The keystone customer id', required=True) - parser.add_argument('--region-id', - help='The region id for which to bootstrap the keystone endpoint', - required=True) - parser.add_argument('--config-token', - help='config access token, also looks for ' - 'env[\'CONSUL_HTTP_TOKEN\']') - return parser.parse_args() - - -def random_string(length=16): - """ - generate a string made of random numbers and letters that always starts - with a letter. - """ - secret_chars = string.ascii_letters + string.digits - return ''.join([random.SystemRandom().choice(string.ascii_letters)] + - [random.SystemRandom().choice(secret_chars) - for _ in range(length - 1)]) - - -def add_keystone_user(consul, customer_uuid): - """ - Add configuration to both the vouch and keystone spaces. Will not - overwrite existing user parameters. All in a single consul transaction. - """ - # FIXME: The user appears twice to match the pattern of other services that - # need a keystone user. Since confd can't look outside its prefix, the user - # needs to be both in the region and the global keystone area. consul-template - # will help with this. Since vouch is actually in the global space, this isn't - # a problem, but I'm going to follow this pattern now until I can come up with - # a better solution for the general problem. - keystone_prefix = 'keystone/users/vouch/' - vouch_prefix = 'vouch/keystone_user/' - with consul.prefix('customers/%s' % customer_uuid): - try: - password = consul.kv_get('%spassword' % keystone_prefix) - LOG.info('Using existing keystone password...') - except requests.HTTPError as e: - if e.response.status_code == 404: - LOG.info('Generating new keystone password...') - password = random_string() - else: - raise - - updates = {} - for prefix in [keystone_prefix, vouch_prefix]: - updates[prefix + 'email'] = 'vouch' - updates[prefix + 'password'] = password - updates[prefix + 'project'] = 'services' - updates[prefix + 'role'] = 'admin' - consul.kv_put_txn(updates) - LOG.info('Added vouch user') - - -def fabricate_missing_data(consul, customer_uuid, region_uuid): - - LOG.info(f'fabricating regional vouch config for {region_uuid}') - - cert_version = consul.kv_get(f'customers/{customer_uuid}/regions/{region_uuid}/certs/current_version') - - # Obtain the shared_ca_name, which is quite possibly clobbered. We only need the very first component - # of this, the secrets engine, which might look like "pki" or "pki_prod" or "pki_pmkft", etc. - - # looks like "pki/versioned/9d524532-61f0-41ac-a85a-64a3f5ac0656/v0" - - shared_ca_name = consul.kv_get(f'customers/{customer_uuid}/vouch/ca_name') - secrets_engine = shared_ca_name.split("/")[0] - - ca_name = f'{secrets_engine}/versioned/{region_uuid}/{cert_version}' - - # Our ca_common_name is always the DU shortname - - du_fqdn = consul.kv_get(f'customers/{customer_uuid}/regions/{region_uuid}/fqdn') - ca_common_name = du_fqdn.split(".")[0] - - # Our ca_signing_role is per-region, but used to be per-customer - - ca_signing_role = f'hosts-{region_uuid}' - - # The server key is strange, since this seems to be an unneeded abstraction. We have - # always called it 'dev' for some reason, so this is hardcoded in deccaxon and vouch now. - - server_key = f'customers/{customer_uuid}/vault_servers/dev' - - # Global across all regions - - vault_server = consul.kv_get(f'{server_key}/url') - - # The admin token has policies: [default kplane] - # This is independent of region. - - admin_token = consul.kv_get(f'customers/{customer_uuid}/vault_servers/dev/admin_token') - - # Construct a tree to place under the region services "vouch" section - - vault_tree = { - 'url': vault_server, - 'server_key': server_key, - } - - vault_servers_tree = { - 'dev': { - 'admin_token': admin_token, - 'url': vault_server, - } - } - - # these were in vouch_tree, but they are created at the end of this function - # 'ca_signing_role': ca_signing_role, - # 'host_signing_token': host_signing_token, - - vouch_tree = { - 'ca_common_name': ca_common_name, - 'ca_name': ca_name, - 'vault': vault_tree, - 'vault_servers': vault_servers_tree, - } - - full_tree = { 'customers': { customer_uuid: { 'regions': { region_uuid: { 'services': { 'vouch': vouch_tree }}}}}} - consul.kv_put_dict(full_tree) - - # The earlier, legacy host_signing_token had policies: [default hosts-{customer_uuid}] - # But this has region-specific rules in it so it must be at the region level. - # Instead, generate a new token and policy: - - vault = get_vault_admin_client(consul, customer_uuid) - rolename = create_host_signing_role(vault, consul, customer_uuid, region_uuid) - create_host_signing_token(vault, consul, customer_uuid, region_uuid, rolename) - - return ca_name - - -def get_vault_admin_client(consul, customer_uuid): - region_uuid = os.environ['REGION_ID'] # to minimize signature changes - - try: - ca_name = consul.kv_get(f'customers/{customer_uuid}/regions/{region_uuid}/services/vouch/ca_name') - except requests.HTTPError as e: - if e.response.status_code != 404: - raise - ca_name = fabricate_missing_data(consul, customer_uuid, region_uuid) - - ca_common_name = consul.kv_get(f'customers/{customer_uuid}/regions/{region_uuid}/services/vouch/ca_common_name') - - vault_server_key = consul.kv_get(f'customers/{customer_uuid}/regions/{region_uuid}/services/vouch/vault/server_key') - with consul.prefix(vault_server_key): - url = consul.kv_get('url') - token = consul.kv_get('admin_token') - - return VaultCA(url, token, ca_name, ca_common_name) - - -def create_host_signing_role(vault, consul, customer_id, region_id) -> str: - rolename = 'hosts-%s' % region_id - customer_key: str = f'customers/{customer_id}/regions/{region_id}/services/vouch/ca_signing_role' - try: - val = consul.kv_get(customer_key) - LOG.debug('kv_get for %s returned: %s', customer_key, val) - if val == rolename: - return rolename - except HTTPError as err: - if err.response.status_code != 404: - LOG.error('cannot do kv_get on %s', customer_key, exc_info=err) - raise err - # either a) the signing role hasn't been created, or b) it has a customer-level one - # older signing roles were hosts-{customer_id} not hosts-{region_id} - vault.create_signing_role(rolename) - consul.kv_put(customer_key, rolename) - return rolename - - -def create_host_signing_token(vault, consul, customer_id, region_uuid, rolename, token_rolename='vouch-hosts'): - policy_name = 'hosts-%s' % region_uuid - - customer_vault_url: str = f'customers/{customer_id}/regions/{region_uuid}/services/vouch/vault/url' - customer_vault_hsk: str = f'customers/{customer_id}/regions/{region_uuid}/services/vouch/vault/host_signing_token' - - vault.create_vouch_token_policy(rolename, policy_name) - token_info = vault.create_token(policy_name, token_role=token_rolename) - consul.kv_put(customer_vault_url, vault.addr) - consul.kv_put(customer_vault_hsk, token_info.json()['auth']['client_token']) - - -def parse(): - args = parse_args() - config_url = args.config_url or os.environ.get('CONSUL_HTTP_ADDR', None) - token = args.config_token or os.environ.get('CONSUL_HTTP_TOKEN', None) - consul = Consul(config_url, token=token) - return args, consul - -def new_token(consul, customer_id): - # obtain the region_id from the environment. We do not want to change the - # signature of this function since it is called from outside. - region_id = os.environ['REGION_ID'] - - vault = get_vault_admin_client(consul, customer_id) - rolename = create_host_signing_role(vault, consul, customer_id, region_id) - create_host_signing_token(vault, consul, customer_id, region_id, rolename) diff --git a/bin/init-region b/bin/init-region deleted file mode 100755 index f79d53a..0000000 --- a/bin/init-region +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/env python - -from common import sys, parse, add_keystone_user, new_token - -def main(): - args, consul = parse() - add_keystone_user(consul, args.customer_id) - new_token(consul, args.customer_id) - -if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file diff --git a/bin/renew-token b/bin/renew-token index df01669..50ef4c4 100755 --- a/bin/renew-token +++ b/bin/renew-token @@ -1,10 +1,9 @@ #!/bin/env python -from common import sys, parse, new_token +import sys def main(): - args, consul = parse() - new_token(consul, args.customer_id) + return 0 if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/container/Dockerfile b/container/Dockerfile index 5c609e4..d6f7b7c 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -1,21 +1,48 @@ -from artifactory.platform9.horse/docker-local/pf9-py39-baseimg-alpine:stable +FROM ubuntu:22.04 -RUN apk update && apk add bash \ - && apk add curl \ - && rm -vrf /var/cache/apk/* +RUN apt-get -y update +RUN apt-get -y install gettext-base +RUN apt-get -y install wget +RUN apt-get -y install vim +RUN apt-get -y install curl +RUN apt-get -y install net-tools +RUN apt-get -y install unzip +RUN apt-get -y install python3-dev +RUN apt-get -y install build-essential +RUN apt-get -y install python3-pip +RUN apt-get -y install libmysqlclient-dev +RUN apt-get -y install default-libmysqlclient-dev +RUN apt-get -y install libssl-dev +RUN apt-get -y install mysql-client +RUN apt-get -y install python3-openssl +RUN apt-get -y install pkg-config +RUN apt-get -y install jq + +RUN cd /bin && curl -o kubectl https://s3.us-west-2.amazonaws.com/amazon-eks/1.34.2/2025-11-13/bin/linux/amd64/kubectl +RUN chmod +x /bin/kubectl + +RUN pip install kubernetes +RUN pip install prometheus_client +RUN pip install sanic +RUN pip install requests # install vouch -COPY vouch-sdist.tgz vault-sdist.tgz /tmp/ -RUN pip install --no-cache-dir /tmp/vouch-sdist.tgz \ - /tmp/vault-sdist.tgz \ - && rm -f /tmp/vouch-sdist.tgz /tmp/vault-sdist.tgz \ - && ln -s /usr/local/bin/common.py /root/common.py \ - && ln -s /usr/local/bin/init-region /root/init-region \ - && ln -s /usr/local/bin/renew-token /root/renew-token +#COPY vouch-sdist.tgz vault-sdist.tgz /tmp/ +#RUN pip install --no-cache-dir /tmp/vouch-sdist.tgz \ +# /tmp/vault-sdist.tgz \ +# && rm -f /tmp/vouch-sdist.tgz /tmp/vault-sdist.tgz \ +# && ln -s /usr/local/bin/init-region /root/init-region \ +######### && ln -s /usr/local/bin/renew-token /root/renew-token +RUN mkdir -p /templates /vouch /root +COPY vouch/* /vouch/ +COPY templates/* /templates/ COPY etc/ /etc COPY scripts/ / +# init-region must be in this specific place +COPY scripts/init-region /root/init-region + ARG APP_METADATA LABEL com.platform9.app_metadata=${APP_METADATA} ARG VERSION @@ -25,3 +52,5 @@ LABEL com.platform9.build=${BUILD_ID} LABEL com.platform9.version="${VERSION}-${BUILD_ID}" ARG BRANCH LABEL com.platform9.branch=${BRANCH} + +ENTRYPOINT ["/vouch.sh"] diff --git a/container/etc/confd/templates/paste.ini b/container/etc/confd/templates/paste.ini index 7294f16..422f91b 100644 --- a/container/etc/confd/templates/paste.ini +++ b/container/etc/confd/templates/paste.ini @@ -20,9 +20,9 @@ paste.filter_factory = keystonemiddleware.auth_token:filter_factory auth_type = v3password auth_url = http://localhost:8080/keystone/v3 #memcache_servers = localhost:11211 -username = {{getv "/vouch/keystone_user/email"}} -password = {{getv "/vouch/keystone_user/password"}} -project_name = {{getv "/vouch/keystone_user/project"}} +username = {{ getenv "VOUCH_KEYSTONE_USER" }} +password = {{ getenv "KEYSTONE_PASSWORD" }} +project_name = {{ getenv "VOUCH_KEYSTONE_PROJECT" }} delay_auth_decision = False user_domain_id = default project_domain_id = default diff --git a/container/etc/confd/templates/vouch-keystone.conf b/container/etc/confd/templates/vouch-keystone.conf index dc5da6f..aa3527a 100644 --- a/container/etc/confd/templates/vouch-keystone.conf +++ b/container/etc/confd/templates/vouch-keystone.conf @@ -1,11 +1,4 @@ -{{- $region_id := getenv "REGION_ID" }} -{{- $svc := (printf "/regions/%s/services/vouch" $region_id) }} -vault_addr: {{ getv (printf "%s/vault/url" $svc) }} -vault_token: {{ getv (printf "%s/vault/host_signing_token" $svc) }} -vouch_addr: https://{{ getv (printf "/regions/%s/fqdn" $region_id) }}/vouch -ca_name: {{ getv (printf "%s/ca_name" $svc) }} -ca_common_name: {{ getv (printf "%s/ca_common_name" $svc) }} -signing_role: {{ getv (printf "%s/ca_signing_role" $svc) }} +vouch_addr: https://{{ getenv "FQDN" }}/vouch listen: 0.0.0.0:8448 paste_ini: paste.ini paste_appname: keystone_auth diff --git a/container/etc/confd/templates/vouch-noauth.conf b/container/etc/confd/templates/vouch-noauth.conf index 886c05b..e0e3cae 100644 --- a/container/etc/confd/templates/vouch-noauth.conf +++ b/container/etc/confd/templates/vouch-noauth.conf @@ -1,12 +1,4 @@ -{{- $region_id := getenv "REGION_ID" }} -{{- $svc := (printf "/regions/%s/services/vouch" $region_id) }} -vault_addr: {{getv (printf "%s/vault/url" $svc) }} -vault_token: {{getv (printf "%s/vault/host_signing_token" $svc) }} -vouch_addr: https://{{ getv (printf "/regions/%s/fqdn" $region_id) }}/vouch -vouch_addr: https://{{ getv "/fqdn" }}/vouch -ca_name: {{getv (printf "%s/ca_name" $svc) }} -ca_common_name: {{getv (printf "%s/ca_common_name" $svc) }} -signing_role: {{getv (printf "%s/ca_signing_role" $svc) }} +vouch_addr: https://{{ getenv "FQDN" }}/vouch listen: 127.0.0.1:8558 paste_ini: paste.ini paste_appname: no_auth diff --git a/container/etc/supervisord-keystone.conf b/container/etc/supervisord-keystone.conf index 8828bfa..d2cf7ee 100644 --- a/container/etc/supervisord-keystone.conf +++ b/container/etc/supervisord-keystone.conf @@ -32,7 +32,7 @@ stdout_logfile_maxbytes=0 user=root [program:confd] -command=/usr/bin/confd -backend consul -node %(ENV_CONFIG_HOST_AND_PORT)s -scheme %(ENV_CONFIG_SCHEME)s -watch -prefix /customers/%(ENV_CUSTOMER_ID)s -auth-token %(ENV_CONSUL_HTTP_TOKEN)s +command=/usr/bin/confd -backend env -interval 300 redirect_stderr=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 diff --git a/container/etc/supervisord-noauth.conf b/container/etc/supervisord-noauth.conf index cb57878..544fe71 100644 --- a/container/etc/supervisord-noauth.conf +++ b/container/etc/supervisord-noauth.conf @@ -32,7 +32,7 @@ stdout_logfile_maxbytes=0 user=root [program:confd] -command=/usr/bin/confd -backend consul -node %(ENV_CONFIG_HOST_AND_PORT)s -scheme %(ENV_CONFIG_SCHEME)s -watch -prefix /customers/%(ENV_CUSTOMER_ID)s -auth-token %(ENV_CONSUL_HTTP_TOKEN)s +command=/usr/bin/confd -backend env -interval 300 redirect_stderr=true stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 diff --git a/container/scripts/init-region b/container/scripts/init-region new file mode 100755 index 0000000..41d8139 --- /dev/null +++ b/container/scripts/init-region @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 + +import sys + +def main(): + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/container/scripts/readiness_checks.sh b/container/scripts/readiness_checks.sh index e964c42..ec44bf9 100755 --- a/container/scripts/readiness_checks.sh +++ b/container/scripts/readiness_checks.sh @@ -1,13 +1,5 @@ #!/bin/bash -VAULT_URL=$(curl --header "X-Consul-Token: $CONSUL_HTTP_TOKEN" $CONSUL_HTTP_ADDR/v1/kv/customers/$CUSTOMER_ID/regions/$REGION_ID/services/vouch/vault/url?raw 2>/dev/null) -VAULT_TOKEN=$(grep vault_token /etc/vouch/vouch-keystone.conf | awk '{ print $2 }') - -TOKEN_HEALTH_STATUS=$(curl -o /dev/null -s -w "%{http_code}\n" --header "X-Vault-Token: $VAULT_TOKEN" $VAULT_URL/v1/auth/token/lookup-self) - -if [ $TOKEN_HEALTH_STATUS != "200" ]; then - echo "Service is not healthy, received $TOKEN_HEALTH_STATUS" > /proc/1/fd/1 - exit 1 -fi +# The Consul-era Vouch checked the vault token and status. Right now we have neither. exit 0 diff --git a/container/scripts/vouch.sh b/container/scripts/vouch.sh new file mode 100755 index 0000000..1182fb1 --- /dev/null +++ b/container/scripts/vouch.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +ls -l / + +cat /templates/paste.ini | envsubst > /etc/vouch/paste.ini +cat /templates/$APP.conf | envsubst > /etc/vouch/$APP.conf + +while true; do + echo "/vouch/vouch.py --config /etc/vouch/$APP.conf" + /vouch/vouch.py --config /etc/vouch/$APP.conf + echo vouch crashed! restarting + sleep 15 +done diff --git a/container/templates/paste.ini b/container/templates/paste.ini new file mode 100644 index 0000000..71ba5d8 --- /dev/null +++ b/container/templates/paste.ini @@ -0,0 +1,28 @@ +# Copyright (c) 2017 Platform9 Systems Inc. +# All Rights reserved + +[pipeline:vouch_with_keystone_auth] +pipeline = authtoken vouch + +[app:vouch] +use = call:vouch.wsgi:app_factory + +[composite:keystone_auth] +use = egg:Paste#urlmap +/ = vouch_with_keystone_auth + +[composite:no_auth] +use = egg:Paste#urlmap +/ = vouch + +[filter:authtoken] +paste.filter_factory = keystonemiddleware.auth_token:filter_factory +auth_type = v3password +auth_url = http://localhost:8080/keystone/v3 +#memcache_servers = localhost:11211 +username = $VOUCH_KEYSTONE_USER +password = $KEYSTONE_PASSWORD +project_name = $VOUCH_KEYSTONE_PROJECT +delay_auth_decision = False +user_domain_id = default +project_domain_id = default diff --git a/container/templates/vouch-keystone.conf b/container/templates/vouch-keystone.conf new file mode 100644 index 0000000..0d6d067 --- /dev/null +++ b/container/templates/vouch-keystone.conf @@ -0,0 +1,7 @@ +vouch_addr: https://$FQDN/vouch +listen: 0.0.0.0:8448 +paste_ini: paste.ini +paste_appname: keystone_auth +pecan_conf: config.py +vault_query_interval: 86400 +refresh_period: 31536000 diff --git a/container/templates/vouch-noauth.conf b/container/templates/vouch-noauth.conf new file mode 100644 index 0000000..2fb4ac5 --- /dev/null +++ b/container/templates/vouch-noauth.conf @@ -0,0 +1,7 @@ +vouch_addr: https://$FQDN/vouch +listen: 127.0.0.1:8558 +paste_ini: paste.ini +paste_appname: no_auth +pecan_conf: config.py +vault_query_interval: 86400 +refresh_period: 31536000 diff --git a/setup.py b/setup.py index 8c2a5dc..83e6d80 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ 'pecan==1.5.1', # https://github.com/pecan/pecan/tags 'python-memcached==1.59' #https://github.com/linsomniac/python-memcached/releases ], - scripts=['bin/vouch', 'bin/common.py', 'bin/init-region', 'bin/renew-token'], + scripts=['bin/vouch', 'bin/init-region', 'bin/renew-token'], test_suite='nose.collector', tests_require=['nose', 'mock'], zip_safe=False, diff --git a/vouch/app.py b/vouch/app.py deleted file mode 100644 index 0d2a262..0000000 --- a/vouch/app.py +++ /dev/null @@ -1,10 +0,0 @@ -from pecan import make_app - -def setup_app(config): - app_conf = dict(config.app) - - return make_app( - app_conf.pop('root'), - logging=getattr(config, 'logging', {}), - **app_conf - ) diff --git a/vouch/auth.py b/vouch/auth.py new file mode 100644 index 0000000..4e2b200 --- /dev/null +++ b/vouch/auth.py @@ -0,0 +1,139 @@ +# validate that a provided candidate token is valid + +import asyncio +import logging +import os +import requests +import time + +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') +LOG = logging.getLogger(__name__) + +_token = None +_timestamp = None +_validations = {} + +cache_lock = asyncio.Lock() + +def _get_vouch_keystone_token(): + + global _token + global _timestamp + + infra_fqdn = os.environ["INFRA_FQDN"] + vouch_keystone_user = os.environ["VOUCH_KEYSTONE_USER"] + vouch_keystone_password = os.environ["VOUCH_KEYSTONE_PASSWORD"] + + url = f'https://{infra_fqdn}/keystone/v3/auth/tokens?nocatalog' + + headers = { "Content-Type": "application/json" } + + payload = { + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": vouch_keystone_user, + "domain": { "id": "default" }, + "password": vouch_keystone_password, + } + } + } + } + } + + response = requests.post(url, headers=headers, json=payload) + + if response.status_code != 201: + LOG.info(f'_get_vouch_keystone_token() status_code {response.status_code}') + + if response.status_code < 200 or response.status_code > 299: + # unlikely that this ever occurs, since in most cases an exception is already raised + raise Exception('failed to obtain a vouch keystone token: status code is {response.status_code}') + + subject_token = response.headers.get('X-Subject-Token') + if not subject_token: + raise Exception("ain't got no subject token. this should never occur! ever!") + + _token = subject_token + _timestamp = time.time() + +def get_vouch_keystone_token(): + + if not _timestamp or time.time() - _timestamp > 3600: + success = False + last_error = None + for i in range(0, 5): + try: + _get_vouch_keystone_token() + success = True + break + except Exception as e: + last_error = e + LOG.info(f"error obtaining vouch's keystone token: {e}") + time.sleep(1 + i) + if not success: + raise(f"was never able to obtain a vouch keystone token, error {last_error}") + + return _token + +async def check_cached_token(candidate_token): + + async with cache_lock: + timestamp = _validations.get(candidate_token, 0) + if not timestamp: + return False + if time.time() - timestamp > 3600: + del _validations[candidate_token] + return False + return True + +async def cache_candidate_token(candidate_token): + + async with cache_lock: + _validations[candidate_token] = time.time() + +async def validate_keystone_token(candidate_token): + + cached = await check_cached_token(candidate_token) + if cached: + LOG.info(f'candidate token was in cache, accepted') + return True, None + + # this does not actually need a lock around it + token = get_vouch_keystone_token() + + headers = { + "X-Auth-Token": token, + "X-Subject-Token": candidate_token, + } + + infra_fqdn = os.environ["INFRA_FQDN"] + url = f'https://{infra_fqdn}/keystone/v3/auth/tokens' + + success = False + last_error = None + for i in range(0, 5): + try: + response = requests.get(url, headers=headers) + success = True + break + except Exception as e: + last_error = e + LOG.info(f'failed to validate token: {e}') + time.sleep(1 + i) + + if not success: + raise Exception(f"error validating caller's keystone token: {e}") + + if response.status_code != 200: + # we expect a 200 from the token validation, display if it isn't what we thought + LOG.info(f'response status {response.status_code}') + + if response.status_code >= 200 and response.status_code <= 299: + # but allow any 2xx + await cache_candidate_token(candidate_token) + return True, response.text + + return False, None diff --git a/vouch/backends/__init__.py b/vouch/backends/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vouch/ca.py b/vouch/ca.py new file mode 100644 index 0000000..e5d82ef --- /dev/null +++ b/vouch/ca.py @@ -0,0 +1,48 @@ +# pylint: disable=too-few-public-methods + +import base64 +import logging +import os +import re + +from kubernetes import client as kclient +from kubernetes import config as kconfig + +from sanic import Sanic, response +from sanic.exceptions import SanicException + +LOG = logging.getLogger(__name__) + + +def get_cas(request): + """ + Get the list of all active root CAs + """ + + LOG.info('Fetching list of current ca certificates') + + try: + kconfig.load_kube_config() + except kconfig.ConfigException: + kconfig.load_incluster_config() + + v1 = kclient.CoreV1Api() + namespace = os.environ["NAMESPACE"] + + active_ca = [] + try: + secrets = v1.list_namespaced_secret(namespace=namespace) + except Exception as e: + LOG.error('Could not fetch CA certs from kubernetes:', e) + raise SanicException("Could not fetch CA certs from kubernetes", status_code=500) + + if secrets and secrets.items: + + pattern = '^v\d+-ca-secret$' + for secret in secrets.items: + if re.search(pattern, secret.metadata.name): + data_b64 = secret.data["ca.crt"] + data = base64.b64decode(data_b64) + active_ca.append(str(data.decode())) + + return response.json(active_ca) diff --git a/vouch/common_cert.py b/vouch/common_cert.py new file mode 100644 index 0000000..4d8c92a --- /dev/null +++ b/vouch/common_cert.py @@ -0,0 +1,511 @@ + +# this file is shared by both deccaxon and vouch + +import base64 +import datetime +import logging +import os +import re +import subprocess +import time + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption + +from kubernetes import client as kclient +from kubernetes import config as kconfig +from kubernetes.client.rest import ApiException +from kubernetes.dynamic import DynamicClient + +LOG = logging.getLogger(__name__) + + +def set_logger(new_logger): + LOG = new_logger + + +def config_kubernetes(): + + try: + kconfig.load_kube_config() + except kconfig.ConfigException: + kconfig.load_incluster_config() + + v1 = kclient.CoreV1Api() + certs_api = kclient.CertificatesV1Api() + dyn_client = DynamicClient(kclient.ApiClient()) + + return v1, certs_api, dyn_client + + +def generate_csr(common_name, alt_names, ttl): + + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, common_name) + ]) + + key_usage_ext = x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=True, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ) + + extended_key_usage_ext = x509.ExtendedKeyUsage([ + ExtendedKeyUsageOID.CLIENT_AUTH, + ExtendedKeyUsageOID.SERVER_AUTH, + ]) + + san_ext = x509.SubjectAlternativeName([ x509.DNSName(alt) for alt in alt_names ]) + + csr = x509.CertificateSigningRequestBuilder().subject_name(subject) + + # csr = csr.add_extension(key_usage_ext, critical=False) + csr = csr.add_extension(extended_key_usage_ext, critical=False) + csr = csr.add_extension(san_ext, critical=False) + + signed_csr = csr.sign(private_key, hashes.SHA256()) + + csr_pem = signed_csr.public_bytes(Encoding.PEM) + private_key_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()) + + return private_key_pem, csr_pem + + +def get_latest_ca_cert(format='cert'): + + LOG.info('get_latest_ca_cert') + + v1, _, _ = config_kubernetes() + namespace = os.environ["NAMESPACE"] + + latest_ca_cert = None + latest_ca_version = None + + try: + secrets = v1.list_namespaced_secret(namespace=namespace) + except Exception as e: + LOG.error('could not fetch CA certs from kubernetes:', e) + return None, None, None + + if not secrets or not secrets.items: + return None, None, None + + pattern = '^v(\d+)-ca-secret$' + for secret in secrets.items: + match = re.search(pattern, secret.metadata.name) + if match: + version = int(match.group(1)) + LOG.info(f'{secret.metadata.name}: version {version}') + if not latest_ca_version or latest_ca_version < version and secret.data and 'ca.crt' in secret.data: + cert_b64 = secret.data["ca.crt"] + key_b64 = secret.data["tls.key"] + latest_ca_cert = base64.b64decode(cert_b64) + latest_ca_key = base64.b64decode(key_b64) + latest_ca_version = version + + if latest_ca_cert: + pem = latest_ca_cert.decode() + LOG.info(pem) + if format == 'cert': + c = x509.load_pem_x509_certificate(bytes(pem.encode("utf-8")),default_backend()) + elif format == 'pem': + c = str(latest_ca_cert.decode()) + return c, latest_ca_key, latest_ca_version + else: + return None, None, None + + +def wait_key_from_secret(v1, secret_name, namespace_name, key): + + TIMEOUT=120 + + start = int(time.time()) + while True: + LOG.info(f'checking if new CA cert "{secret_name}" is ready') + secret = None + try: + secret = v1.read_namespaced_secret(secret_name, namespace_name) + except Exception as e: + if 'Reason: Not Found' in str(e): + LOG.info(f'...secret "{secret_name}" not found') + else: + LOG.info(f'failed to read secret "{secret_name}" in namespace "{namespace_name}": {str(e)}') + if secret and secret.data: + if key in secret.data: + data_b64 = secret.data[key] + if len(data_b64) > 10: + cert = base64.b64decode(data_b64).decode("utf-8") + LOG.info('success') + return cert + else: + LOG.info(f'key "{key}" was found but it is much too short') + else: + LOG.info(f'secret does not yet have "{key}" present') + now = int(time.time()) + if now - start > TIMEOUT: + raise(f'failed to obtain new CA certificate after {TIMEOUT} seconds') + time.sleep(3) + +def create_self_signed_issuer(issuer_name): + + namespace = os.environ["NAMESPACE"] + + v1, _, dyn_client = config_kubernetes() + + issuer_api = dyn_client.resources.get(api_version='cert-manager.io/v1', kind='Issuer') + + issuers = issuer_api.get(namespace=namespace) + + exists = False + for issuer in issuers.items: + LOG.info("ISSUER: %s" % issuer['metadata']['name']) + if issuer['metadata']['name'] == issuer_name: + exists = True + + if exists: + LOG.info(f'issuer "{issuer_name} already exists, no need to create') + return + + new_issuer = { + "apiVersion": "cert-manager.io/v1", + "kind": "Issuer", + "metadata": { + "name": issuer_name, + "namespace": namespace, + }, + "spec": { + "selfSigned": {} + } + } + + new_issuer_response = issuer_api.create(body=new_issuer) + LOG.info(new_issuer_response) + + LOG.info(f'self-signed issuer "{issuer_name}" successfully created') + +def create_cert_from_issuer(name, issuer): + + namespace = os.environ["NAMESPACE"] + + secret_name = name + '-secret' + cert_name = name + '-cert' + + three_years = "26280h" + + new_cert = { + "apiVersion": "cert-manager.io/v1", + "kind": "Certificate", + "metadata": { + "name": cert_name, + "namespace": namespace, + }, + "spec": { + "commonName": cert_name, + "isCA": True, + "issuerRef": { + "group": "cert-manager.io", + "kind": "Issuer", + "name": issuer + }, + "privateKey": { + "algorithm": "ECDSA", + "size": 256, + }, + "secretName": secret_name, + "subject": { + "organizationalUnits": [ 'Laurentian' ], + "organizations": [ 'Rutabaga Services' ], + }, + "duration": three_years + } + } + + v1, _, dyn_client = config_kubernetes() + certificate_api = dyn_client.resources.get(api_version='cert-manager.io/v1', kind='Certificate') + + new_cert_response = certificate_api.create(body=new_cert) + LOG.info(new_cert_response) + + cert = wait_key_from_secret(v1, secret_name, namespace, "ca.crt") + + return secret_name, cert_name, cert + +def create_new_ca(name): + + create_self_signed_issuer("self-signed-issuer") + secret_name, cert_name, cert = create_cert_from_issuer(name, "self-signed-issuer") + + namespace = os.environ["NAMESPACE"] + + v1, _, dyn_client = config_kubernetes() + issuer_api = dyn_client.resources.get(api_version='cert-manager.io/v1', kind='Issuer') + + issuer_name = name + '-issuer' + issuers = issuer_api.get(namespace=namespace) + + exists = False + for issuer in issuers.items: + LOG.info("ISSUER: %s" % issuer['metadata']['name']) + if issuer['metadata']['name'] == issuer_name: + exists = True + + if exists: + raise Exception(f'issuer {issuer_name} already exists') + + # create an issuer linked to the above newly generated self-signed cert + + new_issuer = { + "apiVersion": "cert-manager.io/v1", + "kind": "Issuer", + "metadata": { + "name": issuer_name, + "namespace": namespace, + }, + "spec": { + "ca": { + "secretName": secret_name, + } + } + } + + new_issuer_response = issuer_api.create(body=new_issuer) + LOG.info(new_issuer_response) + + return cert + + + + +""" +signing role: + +'key_bits': 2048, +'allow_any_name': True, +'use_csr_sans': False, +'use_csr_common_name': False +""" + + +def get_wanted_or_max_ttl(ttl): + + if not ttl: + # force maximum if none given + ttl = 999999 + + ca, _, latest_version = get_latest_ca_cert() + + if isinstance(ttl, str): + if ttl.endswith('h'): + ttl = ttl[:-1] + wanted_ttl = int(ttl) + + not_after = ca.not_valid_after + now = datetime.datetime.utcnow() + max_possible_ttl = ((not_after - now).total_seconds() / 60 / 60) - 1 + + if max_possible_ttl < wanted_ttl: + final_ttl = max_possible_ttl + else: + final_ttl = wanted_ttl + + LOG.info('wanted cert ttl %sh, ca ttl %sh, using %sh', ttl, max_possible_ttl, final_ttl) + return '{}h'.format(final_ttl), latest_version + + +def get_all_certs(): + + # returns a list of certs in the layout we previously used in Consul + + LOG.info('get_all_certs') + + v1, _, _ = config_kubernetes() + namespace = os.environ["NAMESPACE"] + + latest_ca_version = None + data = {} + + try: + secrets = v1.list_namespaced_secret(namespace=namespace) + except Exception as e: + LOG.error('error accessing secrets to fetch versioned certs from Kubernetes:', e) + return data + + if not secrets or not secrets.items: + LOG.error('no versioned certs were found in the Kubernetes secrets') + return data + + pattern = '^v(\d+)-(.*)-secret$' + for secret in secrets.items: + match = re.search(pattern, secret.metadata.name) + if match: + version = int(match.group(1)) + cert_name = match.group(2) + + version_name = 'v' + str(version) + if version_name not in data: + data[version_name] = {} + + if cert_name == 'ca': + cert_b64 = secret.data["ca.crt"] + else: + cert_b64 = secret.data["tls.crt"] + key_b64 = secret.data['tls.key'] + + cert = str(base64.b64decode(cert_b64)) + key = str(base64.b64decode(key_b64)) + data[version_name][cert_name] = { 'cert': cert, 'key': key } + + if not latest_ca_version or latest_ca_version < version: + latest_ca_version = version + + if latest_ca_version: + data['current_version'] = 'v' + str(latest_ca_version) + + return data + + +def create_cert(cert_name, common_name, alt_names, ttl='13140h'): + + ttl, latest_version = get_wanted_or_max_ttl(ttl) + + namespace = os.environ["NAMESPACE"] + + v1, kcerts_api, dyn_client = config_kubernetes() + + private_key, csr = generate_csr(common_name, alt_names, ttl) + + cert = sign_csr(cert_name, csr, private_key) + return private_key, cert + + +def sign_csr(cert_name, csr, private_key, issuer=None, ip_sans=None, alt_names=None, ttl=None): + + v1, kcerts_api, dyn_client = config_kubernetes() + + namespace = os.environ["NAMESPACE"] + secret_name = cert_name + '-key' + + annotations = { + "experimental.cert-manager.io/private-key-secret-name": secret_name, + "platform9/certificate-regime": "ikr" + } + + LOG.info(f'private_key: {private_key}') + + if type(private_key) == str: + private_key = private_key.encode() + private_key_b64 = base64.b64encode(private_key).decode('utf-8') + + pk_data = { "tls.key": private_key_b64 } + + # create a secret with the private key + + pk_annotations = { + "cert-manager.io/allow-direct-injection": "true", + "platform9/certificate-regime": "ikr" + } + pk_secret = kclient.V1Secret( + metadata=kclient.V1ObjectMeta( + name=secret_name, + annotations=pk_annotations), + type="Opaque", + data=pk_data, + ) + + try: + api_response = v1.create_namespaced_secret(namespace=namespace, body=pk_secret) + except ApiException as e: + if 'already exists' in str(e.body): + LOG.info(f'secret {secret_name} already exists, patching it...') + api_response = v1.patch_namespaced_secret(secret_name, namespace, pk_secret) + else: + raise + + ttl, latest_version = get_wanted_or_max_ttl(ttl) + if not issuer: + signer_name = 'issuers.cert-manager.io/' + namespace + '.' + 'v' + str(latest_version) + '-ca-issuer' + else: + signer_name = 'issuers.cert-manager.io/' + namespace + '.' + issuer + + usages = ['client auth', 'server auth'] + + if alt_names: + san_ext = x509.SubjectAlternativeName([ x509.DNSName(alt) for alt in alt_names ]) + csr = csr.add_extension(san_ext, critical=False) + + LOG.info(f'csr: {csr}') + + if type(csr) == str: + csr = csr.encode() + csr_b64 = base64.b64encode(csr).decode('utf-8') + + k8s_csr = kclient.V1CertificateSigningRequest( + api_version="certificates.k8s.io/v1", + kind="CertificateSigningRequest", + metadata=kclient.V1ObjectMeta(name=cert_name, namespace=namespace, annotations=annotations), + spec=kclient.V1CertificateSigningRequestSpec( + request=csr_b64, + signer_name=signer_name, + usages=usages, + ) + ) + + LOG.info(f'k8s_csr: {k8s_csr}') + + response = kcerts_api.create_certificate_signing_request(body=k8s_csr) + + # LOG.info("k8s_csr response: %s", response) + + success = False + last_exception = None + last_stderr = "" + for i in range(5): + cmd = ['kubectl', 'certificate', 'approve', cert_name] + LOG.info('running: ' + str(cmd)) + try: + result = subprocess.run(cmd, capture_output=True, check=True) + success = True + break + except subprocess.CalledProcessError as e: + last_exception = e + last_stderr = e.stderr + LOG.info(f"kubectl command failed with return code {e.returncode}") + LOG.info("kubectl stderr:", e.stderr) + except FileNotFoundError: + raise Exception("unable to approve certificate: kubectl is not in the PATH") + except Exception as e: + last_exception = e + LOG.info('error attempting to approve "%s": %s', (cert_name, e)) + time.sleep(1+i) + + if not success: + raise Exception(f'unable to approve certificate: {last_exception}: {last_stderr}') + + cert = None + timeout = 120 + start = time.time() + while True: + LOG.info("checking if cert is ready") + csr_body = kcerts_api.read_certificate_signing_request(name=cert_name) + if csr_body.status.certificate is None: + now = time.time() + if now - start > timeout: + raise(f'timed out waiting for certificate creation ({timeout} seconds)') + time.sleep(5) + continue + cert = base64.b64decode(csr_body.status.certificate) + break + + return cert diff --git a/vouch/conf.py b/vouch/conf.py deleted file mode 100644 index b52820f..0000000 --- a/vouch/conf.py +++ /dev/null @@ -1,38 +0,0 @@ - -# pylint: disable=global-statement -import os -import yaml - -CONF = None - -def set_config(config_file): - global CONF - if CONF: - raise RuntimeError('You can only call set_config once!') - with open(config_file) as f: - CONF = yaml.safe_load(f) - - # paste.ini and config.py can be absolute paths in the config. - # If not, check relative to original config - for key, default in [('paste_ini', 'paste.ini'), - ('pecan_conf', 'config.py')]: - path = CONF.pop(key, default) - if os.path.isabs(path): - CONF[key] = path - else: - CONF[key] = os.path.abspath(os.path.join( - os.path.dirname(config_file), path)) - if not os.path.isfile(CONF[key]): - raise RuntimeError('Could not find config file %s at %s' - % (key, CONF[key])) - try: - customer_id = os.environ['CUSTOMER_ID'] - region_id = os.environ['REGION_ID'] - consul_url = os.environ['CONFIG_URL'] - consul_token = os.environ['CONSUL_HTTP_TOKEN'] - CONF['customer_id']=customer_id - CONF['region_id']=region_id - CONF['consul_url']=consul_url - CONF['consul_token']=consul_token - except KeyError as e: - raise RuntimeError('Failed to set consul config, missing environment:', e) diff --git a/vouch/controllers/CA.py b/vouch/controllers/CA.py deleted file mode 100644 index 0334f19..0000000 --- a/vouch/controllers/CA.py +++ /dev/null @@ -1,56 +0,0 @@ -# pylint: disable=too-few-public-methods - -import logging -import pecan -import re - -from pecan import expose -from pecan.rest import RestController - -from vouch.conf import CONF -from firkinize.configstore.consul import Consul - -LOG = logging.getLogger(__name__) - -def _json_error_response(response, code, exc): - """ - json response from an exception object - """ - response.status = code - response.content_type = 'application/json' - response.charset = 'utf-8' - try: - response.json = {'message': '%s: %s' % (exc.__class__.__name__, exc)} - except Exception: - response.json = {'message': 'Request Failed'} - return response - -class ListCAController(RestController): - def __init__(self): - self._consul = Consul( - CONF['consul_url'], - CONF['consul_token'], - ) - self._prefix = 'customers/%s/regions/%s' % (CONF['customer_id'], CONF['region_id']) - - @expose('json') - def get(self): - """ - GET /v1/cas - Get the list of all active root CAs - """ - LOG.info('Fetching list of current ca certificates') - try: - certs = self._consul.kv_get_prefix(self._prefix+ '/certs') - active_ca = [] - for k in certs: - pattern = '^'+ self._prefix +'/certs/v\d+/ca/cert$' - if re.search(pattern,k): - active_ca.append(certs[k]) - pecan.response.status = 200 - pecan.response.json = active_ca - except Exception as e: - LOG.error('Could not fetch CA certs from consul', e) - return _json_error_response(pecan.response, 500, e) - pecan.response.status = 500 - return pecan.response diff --git a/vouch/controllers/__init__.py b/vouch/controllers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vouch/controllers/creds.py b/vouch/controllers/creds.py deleted file mode 100644 index e053b9b..0000000 --- a/vouch/controllers/creds.py +++ /dev/null @@ -1,60 +0,0 @@ -# pylint: disable=too-few-public-methods - -import logging -import pecan -import re - -from pecan import expose -from pecan.rest import RestController - -from vouch.conf import CONF -from firkinize.configstore.consul import Consul -import requests - -LOG = logging.getLogger(__name__) - -def _json_error_response(response, code, exc): - """ - json response from an exception object - """ - response.status = code - response.content_type = 'application/json' - response.charset = 'utf-8' - try: - response.json = {'message': '%s: %s' % (exc.__class__.__name__, exc)} - except Exception: - response.json = {'message': 'Request Failed'} - return response - -class ListCredsController(RestController): - def __init__(self): - self._consul = Consul( - CONF['consul_url'], - CONF['consul_token'], - ) - self._prefix = 'customers/%s' % (CONF['customer_id']) - - @expose('json') - def get(self, user): - """ - GET /v1/creds/ - Get the keystone credentials for a user - :param user: The user to get the credentials for - :returns: 200 with the credentials - """ - LOG.info('Fetching credentials for user') - try: - creds = self._consul.kv_get(self._prefix+ '/keystone/users/%s/password' % user) - pecan.response.status = 200 - pecan.response.json = creds - except requests.exceptions.HTTPError as e: - if e.response is not None and e.response.status_code == 404: - LOG.error('Credentials not found for user: %s', user) - return _json_error_response(pecan.response, 404, f'Credentials not found for user: {user}') - else: - LOG.error('HTTP error while fetching credentials: %s', e) - return _json_error_response(pecan.response, 500, str(e)) - except Exception as e: - LOG.error('Could not fetch credential from consul', e) - return _json_error_response(pecan.response, 500, e) - return pecan.response diff --git a/vouch/controllers/root.py b/vouch/controllers/root.py deleted file mode 100644 index a2e98ad..0000000 --- a/vouch/controllers/root.py +++ /dev/null @@ -1,20 +0,0 @@ -from vouch.controllers.v1 import V1Controller -from vouch.controllers.metrics_controller import MetricsController -from pecan import expose -from pecan.rest import RestController - -from vouch.conf import CONF - -class RootController(RestController): - v1 = V1Controller() - metrics = MetricsController() - - @expose('json') - def get(self): - """ - Get links to the available versions - """ - vouch_addr = CONF.get('vouch_addr', 'unknown') - return { - 'v1': '%s/v1' % vouch_addr - } diff --git a/vouch/controllers/sign.py b/vouch/controllers/sign.py deleted file mode 100644 index 3a920a2..0000000 --- a/vouch/controllers/sign.py +++ /dev/null @@ -1,127 +0,0 @@ -# pylint: disable=too-few-public-methods - -import logging -import pecan -import requests -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from pecan import expose -from pecan.rest import RestController - -from vouch.conf import CONF -from vaultlib.ca import VaultCA - - -LOG = logging.getLogger(__name__) - -class CAController(RestController): - def __init__(self): - self._vault = VaultCA( - CONF['vault_addr'], - CONF['vault_token'], - CONF['ca_name'], - CONF['ca_common_name'], - ) - - @expose('json') - def get(self): - """ - GET /v1/sign/ca - Get the CA cert of the currently configured CA - """ - ca_name = CONF['ca_name'] - LOG.info('Fetching current ca certificate for %s.', ca_name) - try: - resp = self._vault.get_ca() - pecan.response.status = 200 - pecan.response.json = resp.json()['data'] - LOG.info('status: 200') - except requests.HTTPError as e: - pecan.response.status = e.response.status_code - pecan.response.json = e.response.json() - LOG.info('status: %s', e.response.status_code) - LOG.info('response json: %s', e.response.json()) - return pecan.response - - @expose('json') - def post(self): - """ - POST /v1/sign/ca - Generate a new CA root certificate. Returns the old one and the - new one. - """ - resp_json = {} - ca_name = CONF['ca_name'] - ca_common_name = CONF['ca_common_name'] - - LOG.info('Refreshing ca certificate for %s.', ca_name) - try: - resp_old = self._vault.get_ca() - resp_json['previous'] = resp_old.json()['data'] - resp_new = self._vault.new_ca_root(ca_common_name) - resp_json['new'] = resp_new.json()['data'] - pecan.response.status = 200 - pecan.response.json = resp_json - LOG.info('status: 200') - except requests.HTTPError as e: - pecan.response.status = e.response.status_code - pecan.response.json = e.response.json() - LOG.info('status: %s', e.response.status_code) - LOG.info('response json: %s', e.response.json()) - return pecan.response - - -class CertController(RestController): - def __init__(self): - self._vault = VaultCA( - CONF['vault_addr'], - CONF['vault_token'], - CONF['ca_name'], - CONF['ca_common_name'], - ) - - @expose('json') - def post(self): - """ - POST /v1/sign/cert - Sign a CSR. Body should at least include the 'csr' attribute - containing a PEM encoded CSR. May also include a list of alt_names, - ip_sans, and a ttl (e.g. 780h) - """ - req = pecan.request.json - if not 'csr' in req: - pecan.response.status = 400 - pecan.response.json = { - 'error': 'A POST to /v1/sign/cert must include a csr.' - } - else: - csr = x509.load_pem_x509_csr(str(req['csr']).encode('utf-8'), default_backend()) - LOG.info('Received CSR \'%s\', subject = %s', req['csr'], csr.subject) - ca_name = CONF['ca_name'] - signing_role = CONF['signing_role'] - csr = req['csr'] - common_name = req.get('common_name', None) - ip_sans = req.get('ip_sans', []) - alt_names = req.get('alt_names', []) - # Set the TTL to be a year. We may want to bring this to a lower - # value when we have a robust host side certificate handling in - # place. - ttl = req.get('ttl', '8760h') - try: - resp = self._vault.sign_csr(signing_role, csr, - common_name, ip_sans, alt_names, ttl) - pecan.response.json = resp.json()['data'] - pecan.response.status = 200 - LOG.info('status: 200') - except requests.HTTPError as e: - pecan.response.status = e.response.status_code - pecan.response.json = e.response.json() - LOG.info('status: %s', e.response.status_code) - LOG.info('response json: %s', e.response.json()) - - return pecan.response - - -class SignController(object): - ca = CAController() - cert = CertController() diff --git a/vouch/controllers/v1.py b/vouch/controllers/v1.py deleted file mode 100644 index 13aaa11..0000000 --- a/vouch/controllers/v1.py +++ /dev/null @@ -1,8 +0,0 @@ -from vouch.controllers.sign import SignController -from vouch.controllers.CA import ListCAController -from vouch.controllers.creds import ListCredsController - -class V1Controller(object): - sign = SignController() - cas = ListCAController() - creds = ListCredsController() diff --git a/vouch/creds.py b/vouch/creds.py new file mode 100644 index 0000000..63737f4 --- /dev/null +++ b/vouch/creds.py @@ -0,0 +1,46 @@ +# pylint: disable=too-few-public-methods + +import base64 +import logging +import os + +from sanic import Sanic, response + +from kubernetes import client as kclient +from kubernetes import config as kconfig + +from sanic.exceptions import SanicException + +LOG = logging.getLogger(__name__) + + +def get_keystone_creds(request, user): + """ + GET /v1/creds/ + Get the keystone credentials for a user + :param user: The user to get the credentials for + :returns: 200 with the credentials + """ + LOG.info(f'fetching credentials for user "{user}"') + + whitelist = os.environ["CREDS_API_USER_WHITELIST"] + whitelist = whitelist.split(",") + whitelist = [w.strip() for w in whitelist] + + if user not in whitelist: + raise SanicException(f'user {user} is not whitelisted for this API') + + # all keystone passwords should be in the customer secret with this pattern + kp_env_var = user.upper() + '_KEYSTONE_PASSWORD' + + # notes from tdell + # This function seems like a TERRIBLE idea. If possible, the + # credentials should be provided to the consumer as environment + # variables. If this is not possible, at the very least this + # function should whitelist appropriate usernames. + # The only usage I could locate was by Castellan, and that doesn't + # seem to be packaged presently. + + password = os.environ.get(kp_env_var, "") + + return response.json(password) diff --git a/vouch/controllers/metrics_controller.py b/vouch/metrics_controller.py similarity index 70% rename from vouch/controllers/metrics_controller.py rename to vouch/metrics_controller.py index 3137e57..0eb618d 100644 --- a/vouch/controllers/metrics_controller.py +++ b/vouch/metrics_controller.py @@ -1,55 +1,44 @@ +import base64 import logging -import pecan +import os import time import requests from cryptography import x509 from cryptography.hazmat.backends import default_backend -from pecan import expose -from pecan.rest import RestController +from sanic import Sanic, response -from vouch.conf import CONF -from vaultlib.ca import VaultCA from prometheus_client import generate_latest, Gauge +from kubernetes import client as kclient +from kubernetes import config as kconfig + +from common_cert import get_latest_ca_cert + +# Exporter gauges g_ca_cert_refresh_needed = Gauge('refresh_needed', 'Is CA cert refresh needed?') g_ca_cert_expiry_time = Gauge('cert_expiry_time', 'Time in seconds till CA cert expires') - LOG = logging.getLogger(__name__) -def query_vault(vault): - resp = vault.get_ca() - cert = resp.json()['data']['certificate'] - c=x509.load_pem_x509_certificate(cert.encode('utf-8'),default_backend()) - return c - +""" class MetricsController(RestController): def __init__(self): - self._vault = VaultCA( - CONF['vault_addr'], - CONF['vault_token'], - CONF['ca_name'], - CONF['ca_common_name'], - ) self.last_update_time = time.time() - cert = query_vault(self._vault) + cert, _, _ = get_latest_ca_cert() self.cert_expiration_time = cert.not_valid_after - def get_cert_expiration_time(self): current_time = time.time() if ((current_time - self.last_update_time) > CONF['vault_query_interval']): - cert = query_vault(self._vault) + cert, _ = get_latest_ca_cert() self.cert_expiration_time = cert.not_valid_after self.last_update_time = current_time return self.cert_expiration_time @expose(content_type='text/plain') def get(self): - """ - GET /metrics - """ + # GET /metrics try: self.get_cert_expiration_time() ca_cert_expiry_time = time.mktime(self.cert_expiration_time.timetuple()) @@ -64,4 +53,5 @@ def get(self): pecan.response.text = e.response.text() return pecan.response else: - return generate_latest() \ No newline at end of file + return generate_latest() +""" diff --git a/vouch/sign.py b/vouch/sign.py new file mode 100644 index 0000000..e6b7bde --- /dev/null +++ b/vouch/sign.py @@ -0,0 +1,168 @@ +# pylint: disable=too-few-public-methods + +import base64 +import json +import logging +import random +import requests +import string + +from sanic import Sanic, response +from sanic.exceptions import SanicException +from sanic.log import logger + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.backends import default_backend + +from kubernetes import client as kclient +from kubernetes import config as kconfig + +from common_cert import get_latest_ca_cert, sign_csr, set_logger + +LOG = logging.getLogger(__name__) +set_logger(LOG.info) + +# return the root CA +def get_ca_data(): + + latest_ca, _, version = get_latest_ca_cert(format='pem') + + reply = {} + reply['revocation_time'] = 0 + reply['revocation_time_rfc3339'] = '' + reply['certificate'] = latest_ca + + return reply + +# replace the root CA +def refresh_ca_data(): + + kconfig.load_kube_config() + v1 = kclient.CoreV1Api() + coa = kclient.CustomObjectsApi() + + latest_ca, _, version = get_latest_ca_cert() + new_version = version + 1 + + new_ca_name = 'v%d-ca' % new_version + _, _, cert = create_new_ca(new_ca_name) + + reply = {} + reply['certificate'] = cert + + return reply + + +def get_current_ca(request): + """ + Get the CA cert of the currently configured CA + """ + + LOG.info('Fetching current ca certificate') + try: + reply = get_ca_data() + LOG.info('status: 200') + return response.json(reply) + except requests.HTTPError as e: + LOG.info('status: %s', e.response.status_code) + raise SanicException("Could not fetch CA certs from kubernetes", status_code=e.response.status_code) + +def generate_new_ca_root_cert(request): + """ + Generate a new CA root certificate. Returns the old one and the new one. + """ + + resp_json = {} + + LOG.info('Refreshing ca certificate') + try: + old_cert_data = get_ca_data() + resp_json['previous'] = json.dumps(old_cert_data) + new_cert_data = refresh_ca_data() + resp_json['new'] = json.dumps(new_cert_data) + LOG.info('status: 200') + return response.json(resp_json) + except requests.HTTPError as e: + LOG.info('status: %s', e.response.status_code) + LOG.info('response json: %s', e.response.json()) + raise SanicException("Unable to refresh CA certificate", status_code=e.response.status_code) + + +def sanitize_and_unique(prefix, cert_name): + + if not cert_name: + raise Exception('illegal for certificate name: ""') + + cert_name = cert_name.lower() + + # 110,075,314,176 possibilities in eight lowercase letters + new_cert_name = prefix + '-' + ''.join(random.choices(string.ascii_lowercase, k=8)) + "--" + + for c in cert_name: + if (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9'): + pass + else: + c = '-' + new_cert_name += c + + if new_cert_name.endswith("-"): + # only a crazy person would end a hostname with a special character. And this would + # create an illegal name under Kubernetes. Apply a French cinematic solution. + new_cert_name += "fin" + + return new_cert_name + + +def sign_cert(request): + + j = request.json + + if 'csr' not in j: + raise SanicException("Signing request must contain a CSR", status_code=400) + + if 'private_key' not in j: + raise SanicException("Signing request must contain a private_key. cert-manager requirement.", status_code=400) + + csr_parsed = x509.load_pem_x509_csr(str(j['csr']).encode('utf-8'), default_backend()) + logger.info(f'Received CSR "{j["csr"]}", subject = "{csr_parsed.subject}"') + + cert_name = None + try: + common_name_attr = csr_parsed.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0] + common_name_string = common_name_attr.value + logger.info(f'Subject Common Name: {common_name_string}') + cert_name = common_name_string + except Exception as e: + logger.info(f'error extracting CN: {e}') + + if not cert_name: + raise Exception("cannot have an empty common name") + + # should this be overridden by a provided common_name parameter outside the CSR? + cert_name = sanitize_and_unique('host', cert_name) + + common_name = j.get('common_name', None) + ip_sans = j.get('ip_sans', []) + alt_names = j.get('alt_names', []) + ttl = j.get("ttl", []) + + logger.info(f'signing CSR {cert_name}') + cert = sign_csr(cert_name, j['csr'], j['private_key'], ip_sans, alt_names, ttl) + logger.info(f'Generated cert: {cert}') + + issuing_ca = "" + try: + issuing_ca, _, _ = get_latest_ca_cert(format='pem') + except Exception as e: + logger.info('failed getting issuing_ca: {e}') + + if type(cert) == bytes: + cert = cert.decode() + if type(issuing_ca) == bytes: + issuing_ca = issuing_ca.decode() + + reply = { "certificate": cert, "issuing_ca": issuing_ca } + logger.info(f'reply: {reply}') + + return response.json(reply) diff --git a/vouch/tests/__init__.py b/vouch/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/vouch/tests/test_sign.py b/vouch/tests/test_sign.py deleted file mode 100644 index 54c76ad..0000000 --- a/vouch/tests/test_sign.py +++ /dev/null @@ -1,7 +0,0 @@ -from unittest import TestCase - -import vouch - -class TestSign(TestCase): - def test_is_string(self): - self.assertTrue(isinstance('string', str)) diff --git a/vouch/vouch.py b/vouch/vouch.py new file mode 100755 index 0000000..fbac39e --- /dev/null +++ b/vouch/vouch.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +import os +import logging + +from sanic import Sanic, response +from sanic.log import logger +from sanic.exceptions import SanicException, Forbidden + +from auth import validate_keystone_token +from ca import get_cas +from sign import get_current_ca, generate_new_ca_root_cert, sign_cert +from creds import get_keystone_creds +from common_cert import set_logger + +LOG = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s') + +app1 = Sanic("vouch-keystone") +app2 = Sanic("vouch-noauth") + +KEYSTONE_PORT = 8448 +NOAUTH_PORT = 8558 + +async def validate(request): + + logger.info(f'[{request.app.name}] {request.path}') + + if request.app.name == 'vouch-noauth': + return True, None + + candidate_token = request.headers.get('X-Auth-Token') + validated, text = await validate_keystone_token(candidate_token) + + if text: + logger.info(f'validated result: {text}') + + if not validated: + return False, response.text("Unauthorized", status=401) + # TODO: verify that this is an admin token + + return True, None + +# root, this is used as a test of reachability + +@app1.route("/", methods=["GET"]) +@app2.route("/", methods=["GET"]) +async def root(request): + + validated, rv = await validate(request) + if not validated: + return rv + + region_fqdn = os.environ["REGION_FQDN"] + + reply = { 'v1': f'https://{region_fqdn}/vouch/v1' } + return response.json(reply) + +# ping. No permissions required + +@app1.route("/ping", methods=["GET"]) +@app2.route("/ping", methods=["GET"]) +async def ping(request): + + return response.text(f'{request.app.name} pong\n') + +# request list of CAs + +@app1.route("/v1/cas", methods=["GET"]) +@app2.route("/v1/cas", methods=["GET"]) +async def v1_cas(request): + + validated, rv = await validate(request) + if not validated: + return rv + + return get_cas(request) + +# get current CA cert + +@app1.route("/v1/sign/ca", methods=["GET"]) +@app2.route("/v1/sign/ca", methods=["GET"]) +async def v1_get_current_ca(request): + + validated, rv = await validate(request) + if not validated: + return rv + + return get_current_ca(request) + +# generate a new CA root cert + +@app1.route("/v1/sign/ca", methods=["POST"]) +@app2.route("/v1/sign/ca", methods=["POST"]) +async def v1_generate_new_ca_root_cert(request): + + validated, rv = await validate(request) + if not validated: + return rv + + return generate_new_ca_root_cert(request) + +# sign a host cert + +@app1.route("/v1/sign/cert", methods=["POST"]) +@app2.route("/v1/sign/cert", methods=["POST"]) +async def v1_sign_cert(request): + + validated, rv = await validate(request) + if not validated: + return rv + + return sign_cert(request) + +# obtain service user credential + +@app1.route("/v1/creds/", methods=["GET"]) +@app2.route("/v1/creds/", methods=["GET"]) +async def v1_get_keystone_creds(request, user): + + validated, rv = await validate(request) + if not validated: + return rv + + return get_keystone_creds(request, user) + + +if __name__ == "__main__": + + LOG.info("This is Radio Vouch") + + set_logger(logger.info) + + app1.prepare(host="0.0.0.0", port=KEYSTONE_PORT) + app2.prepare(host="0.0.0.0", port=NOAUTH_PORT) + + Sanic.serve() + + LOG.info("O, untimely death!") diff --git a/vouch/wsgi.py b/vouch/wsgi.py deleted file mode 100644 index cea1b97..0000000 --- a/vouch/wsgi.py +++ /dev/null @@ -1,5 +0,0 @@ -from pecan.deploy import deploy - -# paste factory: -def app_factory(global_config, **local_conf): - return deploy(global_config['config'])