diff --git a/.gitignore b/.gitignore index d6db400..79084c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.pyc +.*.swp /*.egg-info /.tox *.iml .idea/ +/venv-vergilius diff --git a/Dockerfile b/Dockerfile index 67b2f0d..5d64eef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,22 +4,22 @@ MAINTAINER Vasiliy Ostanin RUN add-apt-repository ppa:nginx/development RUN apt-get update #apt-get upgrade -y -o Dpkg::Options::="--force-confold" && \ -RUN apt-get install -y ca-certificates nginx git-core python build-essential autoconf libtool \ - python-dev libffi-dev libssl-dev python-pip dialog nano +RUN apt-get install -y ca-certificates nginx git-core python3 build-essential autoconf libtool \ + python3-dev libffi-dev libssl-dev python3-pip dialog nano ENV TERM screen -ADD init.d/01_env.sh /etc/init.d/ -ADD services/nginx.sh /etc/service/nginx/run -ADD services/vergilius.sh /etc/service/vergilius/run +COPY docker/init.d/01_env.sh /etc/init.d/ +COPY docker/services/nginx.sh /etc/service/nginx/run +COPY docker/services/vergilius.sh /etc/service/vergilius/run -COPY consul/* /etc/consul/conf.d/ -COPY nginx/conf.d/*.conf /etc/nginx/conf.d/ -COPY nginx/nginx.conf /etc/nginx/nginx.conf +COPY docker/consul/* /etc/consul/conf.d/ +COPY docker/nginx/conf.d/*.conf /etc/nginx/conf.d/ +COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf RUN rm /etc/nginx/sites-enabled/* && mkdir -p /etc/nginx/sites-enabled/certs && \ mkdir -p /data/dummy_ca/domains/ -ADD src /opt/vergilius -RUN cd /opt/vergilius/ && python setup.py install +COPY src /opt/vergilius +RUN cd /opt/vergilius/ && python3 setup.py install WORKDIR /opt/vergilius/ EXPOSE 80 443 diff --git a/circle.yml b/circle.yml index 2c63ac9..a3ea40b 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ machine: python: - version: 2.7 + version: 3.5.2 services: - docker environment: @@ -25,4 +25,4 @@ deployment: dockerhub: branch: master commands: - - 'curl -H "Content-Type: application/json" --data "{\"source_type\": \"Branch\", \"source_name\": \"master\"}" -X POST https://registry.hub.docker.com/u/devopsftw/vergilius/trigger/ea3f932c-49b9-47e8-af0c-ec1d8615cda4/' \ No newline at end of file + - 'curl -H "Content-Type: application/json" --data "{\"source_type\": \"Branch\", \"source_name\": \"master\"}" -X POST https://registry.hub.docker.com/u/devopsftw/vergilius/trigger/ea3f932c-49b9-47e8-af0c-ec1d8615cda4/' diff --git a/consul/vergilius-443.json b/docker/consul/vergilius-443.json similarity index 100% rename from consul/vergilius-443.json rename to docker/consul/vergilius-443.json diff --git a/consul/vergilius-80.json b/docker/consul/vergilius-80.json similarity index 100% rename from consul/vergilius-80.json rename to docker/consul/vergilius-80.json diff --git a/consul/vergilius-admin.json b/docker/consul/vergilius-admin.json similarity index 100% rename from consul/vergilius-admin.json rename to docker/consul/vergilius-admin.json diff --git a/init.d/01_env.sh b/docker/init.d/01_env.sh similarity index 100% rename from init.d/01_env.sh rename to docker/init.d/01_env.sh diff --git a/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf similarity index 100% rename from nginx/conf.d/default.conf rename to docker/nginx/conf.d/default.conf diff --git a/nginx/nginx.conf b/docker/nginx/nginx.conf similarity index 100% rename from nginx/nginx.conf rename to docker/nginx/nginx.conf diff --git a/services/nginx.sh b/docker/services/nginx.sh similarity index 100% rename from services/nginx.sh rename to docker/services/nginx.sh diff --git a/services/vergilius.sh b/docker/services/vergilius.sh similarity index 100% rename from services/vergilius.sh rename to docker/services/vergilius.sh diff --git a/requirements.txt b/requirements.txt index 53fbe21..ce1d77c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,10 @@ zope.component==4.2.2 zope.event==4.1.0 zope.interface==4.1.3 -python-consul==0.4.7 -tornado==4.3 +python-consul==0.7 +tornado==4.4.2 funcsigs==1.0.0 mock==1.3.0 +acme==0.9.3 +cryptography==1.6 +PyOpenSSL==16.2 diff --git a/src/app.py b/src/app.py index 9261531..a41ecd3 100755 --- a/src/app.py +++ b/src/app.py @@ -1,18 +1,19 @@ -#!/usr/bin/python +#!/usr/bin/python3 import logging import signal - import time -import tornado -import vergilius -from vergilius import logger -from vergilius.loop.nginx_reloader import NginxReloader -from vergilius.loop.service_watcher import ServiceWatcher +import tornado.ioloop +import vergilius.base +from vergilius.cert import AcmeCertificateProvider +from vergilius.loop import NginxReloader, ServiceWatcher +from vergilius import config MAX_WAIT_SECONDS_BEFORE_SHUTDOWN = 10 -logger.setLevel(logging.DEBUG) +logging.basicConfig(format='%(asctime)s %(levelname)s:%(name)s %(message)s') +logger = logging.getLogger(__name__) +logger.setLevel(config.LOG_LEVEL) def shutdown(): @@ -35,30 +36,36 @@ def stop_loop(): stop_loop() -def sig_handler(sig, frame): +def sig_handler(sig, _): logger.warning('Caught signal: %s', sig) tornado.ioloop.IOLoop.instance().add_callback(shutdown) + def handle_future(f): tornado.ioloop.IOLoop.current().stop() - if f.exception() != None: + if f.exception() is not None: raise f.exception() + def main(): signal.signal(signal.SIGTERM, sig_handler) signal.signal(signal.SIGINT, sig_handler) - vergilius.Vergilius.init() - - consul_handler = ServiceWatcher().watch_services() - nginx_reloader = NginxReloader().nginx_reload() + app = App() + sw = ServiceWatcher(app) io_loop = tornado.ioloop.IOLoop.current() - io_loop.add_future(consul_handler, handle_future) - io_loop.add_future(nginx_reloader, handle_future) + io_loop.add_future(sw.watch_services(), handle_future) + io_loop.add_future(app.nginx_reloader.reload(), handle_future) io_loop.start() +class App(object): + def __init__(self): + self.session = vergilius.base.ConsulSession() + self.certificate_provider = AcmeCertificateProvider() + self.nginx_reloader = NginxReloader() + if __name__ == '__main__': main() diff --git a/src/setup.py b/src/setup.py index 4bb25c8..73c6588 100644 --- a/src/setup.py +++ b/src/setup.py @@ -2,11 +2,14 @@ from setuptools import find_packages install_requires = [ - 'python-consul', - 'tornado', + 'python-consul==0.7.0', + 'tornado==4.4.2', 'setuptools>=1.0', 'zope.component', 'zope.interface', + 'acme==0.9.3', + 'cryptography==1.6', + 'PyOpenSSL==16.2', ] setup( diff --git a/src/vergilius/__init__.py b/src/vergilius/__init__.py index 7f9268f..e69de29 100644 --- a/src/vergilius/__init__.py +++ b/src/vergilius/__init__.py @@ -1,25 +0,0 @@ -import logging -import os - -from consul import Consul -from consul import tornado as consul_from_tornado -from tornado import template - -import config -from components.dummy_certificate_provider import DummyCertificateProvider -from vergilius.models.identity import Identity - -logger = logging.getLogger(__name__) -template_loader = template.Loader(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates')) -certificate_provider = DummyCertificateProvider() - -consul = Consul(host=config.CONSUL_HOST) -consul_tornado = consul_from_tornado.Consul(host=config.CONSUL_HOST) - - -class Vergilius(object): - identity = None - - @classmethod - def init(cls): - cls.identity = Identity() diff --git a/src/vergilius/base.py b/src/vergilius/base.py new file mode 100644 index 0000000..cde5ee5 --- /dev/null +++ b/src/vergilius/base.py @@ -0,0 +1,56 @@ +import logging +import consul +from consul.tornado import Consul as TornadoConsul + +from tornado.ioloop import IOLoop +from tornado.locks import Event +import tornado.gen + +from vergilius import config + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +tc = TornadoConsul(host=config.CONSUL_HOST) + + +class ConsulSession(object): + def __init__(self): + self._sid = None + self._waitSid = Event() + IOLoop.instance().spawn_callback(self.watch) + pass + + @tornado.gen.coroutine + def watch(self): + while True: + tick = tornado.gen.sleep(5) + yield self.ensure_session() + yield tick + + @tornado.gen.coroutine + def ensure_session(self): + if self._sid is None: + self._sid = yield self.create_session() + self._waitSid.set() + else: + try: + yield tc.session.renew(self._sid) + except consul.NotFound: + self._waitSid.clear() + logger.error('session not found, trying to recreate') + self._sid = yield self.create_session() + self._waitSid.set() + except consul.ConsulException as e: + logger.error('consul exception: %s' % e) + return True + + @tornado.gen.coroutine + def create_session(self): + sid = yield tc.session.create('vergilius', ttl=10, behavior='delete', lock_delay=0) + logger.debug('session created: %s', sid) + return sid + + @tornado.gen.coroutine + def get_sid(self): + yield self._waitSid.wait() + return self._sid diff --git a/src/vergilius/cert.py b/src/vergilius/cert.py new file mode 100644 index 0000000..c4f2d51 --- /dev/null +++ b/src/vergilius/cert.py @@ -0,0 +1,257 @@ +import base64 +import datetime +import logging +from concurrent.futures import ThreadPoolExecutor + +import tornado.gen +import tornado.web +from tornado.httpclient import HTTPClient +from vergilius import config +from consul.tornado import Consul as TornadoConsul +from consul import Consul + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import rsa +import OpenSSL + +from acme import client, messages, jose +import time + +logger = logging.getLogger(__name__) +logger.setLevel(config.LOG_LEVEL) +thread_pool = ThreadPoolExecutor(4) +tc = TornadoConsul(host=config.CONSUL_HOST) + + +def urlsafe_b64(b): + return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") + + +class AcmeCertificateProvider(object): + cc = Consul(host=config.CONSUL_HOST) + _acme = None + acme_key = None + + def __init__(self): + self.app = self.make_app() + self.app.listen(8888) + self.fetch_key() + self.init_acme() + + @classmethod + def make_app(cls): + return tornado.web.Application([ + (r"/.well-known/acme-challenge/(.+)", AcmeChallengeHandler), + ]) + + def fetch_key(self): + index, key_data = self.cc.kv.get('vergilius/acme/private_key') + if key_data: + private_key = serialization.load_pem_private_key(key_data['Value'], + password=None, backend=default_backend()) + else: + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + key_data = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + self.cc.kv.put('vergilius/acme/private_key', key_data) + self.acme_key = jose.JWKRSA(key=private_key) + + def init_acme(self): + self._acme = client.Client(config.ACME_DIRECTORY_URL, self.acme_key) + try: + regr = self._acme.register() + self._acme.agree_to_tos(regr) + except Exception as e: + logger.error('acme certificate provider error: %s' % e) + + @classmethod + def parse_token(cls, authzr): + for c in authzr.body.challenges: + json = c.chall.to_partial_json() + if json['type'] == 'http-01': + return json['token'] + + def store_token(self, token): + # put token to consul KV + thumbprint = urlsafe_b64(self.acme_key.thumbprint()) + keyauth = '{0}.{1}'.format(token, thumbprint) + self.cc.kv.put('vergilius/acme/challenge/%s' % token, keyauth) + + def delete_token(self, token): + self.cc.kv.delete('vergilius/acme/challenge/%s' % token) + + def auth_domain(self, domain): + """ + request and solve ACME challenge for domain + :param domain: string + :return: + :rtype:client.messages.AuthorizationResource + """ + # request challenges for domain + authzr = self._acme.request_domain_challenges(domain) + token = self.parse_token(authzr) + self.store_token(token) + + challenge = [x for x in authzr.body.challenges if x.typ == 'http-01'][0] + response, validation = challenge.response_and_validation(self.acme_key) + self._acme.answer_challenge(challenge, response) + + wait_until = time.time() + 30 + while time.time() < wait_until: + logger.debug('polling challenge result for %s' % domain) + authzr, authzr_response = self._acme.poll(authzr) + logger.debug('status for %s is %s' % domain, authzr.body.status) + if authzr.body.status not in (messages.STATUS_VALID, messages.STATUS_INVALID): + time.sleep(2) + else: + break + self.delete_token(token) + return authzr + + @classmethod + def create_csr(cls, domains): + """ + create private key and csr + :param domains: + :return: tuple with private key and csr in OpenSSL library format + """ + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + first_domain = domains[0] + csr = x509.CertificateSigningRequestBuilder().subject_name( + x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, 'RU'), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, 'Yekaterinburg'), + x509.NameAttribute(NameOID.LOCALITY_NAME, 'Yekaterinburg'), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'devopsftw'), + x509.NameAttribute(NameOID.COMMON_NAME, first_domain), + ]) + ).add_extension( + x509.SubjectAlternativeName([ + x509.DNSName(domain) for domain in domains + ]), + critical=False, + ).sign(private_key, hashes.SHA256(), default_backend()) + + csr_openssl = OpenSSL.crypto.load_certificate_request( + OpenSSL.crypto.FILETYPE_PEM, + csr.public_bytes(serialization.Encoding.PEM) + ) + return private_key, csr_openssl + + def get_authzrs(self, domains): + """request challenges for each domain""" + authzrs = [self.auth_domain(domain) for domain in domains] + return authzrs + + def request_certificate(self, csr, authzrs): + """request certificates for solved challenges""" + try: + response = self._acme.request_issuance(jose.util.ComparableX509(csr), authzrs) + cert_data = HTTPClient().fetch(response.uri).body + cert = x509.load_der_x509_certificate(cert_data, default_backend()) + return cert + except messages.Error as error: + print("This script is doomed to fail as no authorization " + "challenges are ever solved. Error from server: {0}".format(error)) + return None + + def query_letsencrypt(self, domains): + """ + get and solve letsencrypt challenges, create CSR and request certificate for it + :param domains: + :return: + """ + authzrs = self.get_authzrs(domains) + domain_key, csr = self.create_csr(domains) + cert = self.request_certificate(csr, authzrs) + return domain_key, cert + + @tornado.gen.coroutine + def get_certificate(self, domains): + """Get certificate for requested domains""" + + logger.debug('get cert for domains %s' % domains) + + domain_key, cert = yield thread_pool.submit(self.query_letsencrypt, domains) + + if cert is None: + return None + + key_str = domain_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ) + cert_str = cert.public_bytes(serialization.Encoding.PEM) + expires = int(cert.not_valid_after.timestamp()) + result = { + 'private_key': key_str, + 'public_key': cert_str, + 'expires': expires + + } + return result + + +class AcmeChallengeHandler(tornado.web.RequestHandler): + @tornado.gen.coroutine + def get(self, challenge): + logger.debug('challenge request: %s' % challenge) + index, data = yield tc.kv.get('vergilius/acme/challenge/%s' % challenge) + if data: + self.write(data['Value']) + else: + raise tornado.web.HTTPError(404) + + +class DummyCertificateProvider(object): + @tornado.gen.coroutine + def get_certificate(self, domains): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, 'RU'), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, 'Yekaterinburg'), + x509.NameAttribute(NameOID.LOCALITY_NAME, 'Yekaterinburg'), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'devopsftw'), + x509.NameAttribute(NameOID.COMMON_NAME, domains[0]), + ]) + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=10) + ).add_extension( + x509.SubjectAlternativeName([x509.DNSName(domain) for domain in domains]), + critical=False + ).sign(key, hashes.SHA256(), default_backend()) + + return { + 'private_key': key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ), + 'public_key': cert.public_bytes(serialization.Encoding.PEM), + 'expires': cert.not_valid_after.timestamp() + } diff --git a/src/vergilius/components/__init__.py b/src/vergilius/components/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/vergilius/components/certificate_provider.py b/src/vergilius/components/certificate_provider.py deleted file mode 100644 index 27d23ee..0000000 --- a/src/vergilius/components/certificate_provider.py +++ /dev/null @@ -1,11 +0,0 @@ -import zope.interface - - -class ICertificateProvider(zope.interface.Interface): - def get_certificate(self, id, domains): - """ - :param id: string - :param domains: set - :rtype: object with keys private_key, public_key and expires - """ - pass diff --git a/src/vergilius/components/dummy_certificate_provider.py b/src/vergilius/components/dummy_certificate_provider.py deleted file mode 100644 index 5ed252b..0000000 --- a/src/vergilius/components/dummy_certificate_provider.py +++ /dev/null @@ -1,74 +0,0 @@ -import datetime -import hashlib -import os -import subprocess -import time -import zope.interface - -import vergilius -from vergilius.components.certificate_provider import ICertificateProvider - -OPENSSL = '/usr/bin/openssl' -KEY_SIZE = 1024 -DAYS = 3650 -DATA_PATH = os.path.join(vergilius.config.DATA_PATH, 'dummy_ca') -X509_EXTRA_ARGS = ('-passin', 'pass:%s' % vergilius.config.SECRET) - - -def openssl(*args): - cmdline = [OPENSSL] + list(args) - subprocess.check_call(cmdline) - - -def check_paths(): - if not os.path.exists(DATA_PATH): - os.mkdir(DATA_PATH) - - if not os.path.exists(os.path.join(DATA_PATH, 'domains')): - os.mkdir(os.path.join(DATA_PATH, 'domains')) - - -class DummyCertificateProvider(object): - """ - Issues self signed certificates. - """ - zope.interface.implements(ICertificateProvider) - - @classmethod - def dfile(cls, id, ext): - return os.path.join(DATA_PATH, 'domains', '%s.%s' % (id, ext)) - - def get_certificate(self, id, domains, keysize=KEY_SIZE, days=DAYS): - """ - :param id: string - :type domains: set - """ - check_paths() - - if not os.path.exists(self.dfile(id, 'key')): - openssl('genrsa', '-out', self.dfile(id, 'key'), str(keysize)) - - config_file = open(self.dfile(id, 'config'), 'w') - config_file.write(vergilius.template_loader.load('ssl.html').generate( - domain=sorted(list(domains))[0], dns_list=domains, email=vergilius.config.EMAIL - )) - config_file.close() - - openssl('req', '-new', '-key', self.dfile(id, 'key'), '-out', self.dfile(id, 'request'), - '-config', self.dfile(id, 'config')) - - openssl('x509', '-req', - '-days', str(days), - '-in', self.dfile(id, 'request'), - '-CA', vergilius.Vergilius.identity.get_certificate_path(), - '-CAkey', vergilius.Vergilius.identity.get_private_key_path(), - '-set_serial', - '0x%s' % hashlib.md5(sorted(list(domains))[0] + str(datetime.datetime.now())).hexdigest(), - '-out', self.dfile(id, 'cert'), - '-extensions', 'v3_req', - '-extfile', self.dfile(id, 'config'), - *X509_EXTRA_ARGS) - - return {'private_key': os.path.join(DATA_PATH, self.dfile(id, 'key')), - 'public_key': os.path.join(DATA_PATH, self.dfile(id, 'cert')), - 'expires': int(time.time()) + DAYS * 24 * 60 * 60} diff --git a/src/vergilius/config.py b/src/vergilius/config.py index 0dda231..900104e 100644 --- a/src/vergilius/config.py +++ b/src/vergilius/config.py @@ -11,10 +11,14 @@ ACME_DIRECTORY_URL = os.environ.get('ACME_DIRECTORY_URL', 'https://acme-staging.api.letsencrypt.org/directory') -OPENSSL_BINARY = os.environ.get('OPENSSL_BINARY', '/usr/bin/openssl') - EMAIL = os.environ.get('EMAIL', 'root@localhost') SECRET = os.environ.get('SECRET') -if not SECRET: - raise Exception('No secret specified!') +TEMPLATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates') + +if os.environ.get('ACME_PRODUCTION', 0): + ACME_DIRECTORY_URL = 'https://acme-v01.api.letsencrypt.org/directory' +else: + ACME_DIRECTORY_URL = 'https://acme-staging.api.letsencrypt.org/directory' + +LOG_LEVEL = os.environ.get('LOG_LEVEL', 'DEBUG') diff --git a/src/vergilius/loop.py b/src/vergilius/loop.py new file mode 100644 index 0000000..a0cff4a --- /dev/null +++ b/src/vergilius/loop.py @@ -0,0 +1,80 @@ +import logging +import subprocess + +from consul.tornado import Consul as TornadoConsul +from consul import ConsulException +from consul.base import Timeout as ConsulTimeout + +from vergilius import config +from vergilius.models import Service + +import tornado.gen +from tornado.locks import Event + +logger = logging.getLogger(__name__) +logger.setLevel(config.LOG_LEVEL) +tc = TornadoConsul(host=config.CONSUL_HOST) + + +class NginxReloader(object): + nginx_update_event = Event() + + def __init__(self): + pass + + @classmethod + @tornado.gen.coroutine + def reload(cls): + while True: + yield cls.nginx_update_event.wait() + cls.nginx_update_event.clear() + logger.info('nginx reload') + try: + subprocess.check_call([config.NGINX_BINARY, '-s', 'reload'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError as e: + logger.error('nginx reload fail. stderr: %s' % e.stderr) + + @classmethod + def queue_reload(cls): + cls.nginx_update_event.set() + + +class ServiceWatcher(object): + def __init__(self, app): + self.services = {} + + self.data = {} + self.modified = False + self.app = app + + @tornado.gen.coroutine + def watch_services(self): + index = None + while True: + index = yield self.fetch_services(index) + + @tornado.gen.coroutine + def fetch_services(self, index=None): + try: + index, data = yield tc.catalog.services(index, wait=None) + self.check_services(data) + return index + except ConsulTimeout: + pass + except ConsulException as e: + logger.error('consul error: %s' % e) + yield tornado.gen.sleep(1) + + def check_services(self, data): + # check if service has any of our tags + services_to_publish = dict((k, v) for k, v in data.items() if any(x in v for x in [u'http', u'http2'])) + for service_name in services_to_publish: + if service_name not in self.services: + logger.info('[service watcher]: new service: %s' % service_name) + self.services[service_name] = Service(service_name, self.app) + + # cleanup stale services + for service_name in list(self.services): + if service_name not in services_to_publish.keys(): + logger.info('[service watcher]: removing stale service: %s' % service_name) + del self.services[service_name] diff --git a/src/vergilius/loop/__init__.py b/src/vergilius/loop/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/vergilius/loop/nginx_reloader.py b/src/vergilius/loop/nginx_reloader.py deleted file mode 100644 index a7a89ea..0000000 --- a/src/vergilius/loop/nginx_reloader.py +++ /dev/null @@ -1,25 +0,0 @@ -import subprocess -from consul import tornado -from tornado.locks import Event - -import vergilius - - -class NginxReloader(object): - nginx_update_event = Event() - - def __init__(self): - pass - - @classmethod - @tornado.gen.coroutine - def nginx_reload(cls): - while True: - yield cls.nginx_update_event.wait() - cls.nginx_update_event.clear() - vergilius.logger.info('[nginx]: reload') - subprocess.check_call([vergilius.config.NGINX_BINARY, '-s', 'reload'], stdout=subprocess.DEVNULL) - - @classmethod - def queue_reload(cls): - cls.nginx_update_event.set() diff --git a/src/vergilius/loop/service_watcher.py b/src/vergilius/loop/service_watcher.py deleted file mode 100644 index 2c19ecf..0000000 --- a/src/vergilius/loop/service_watcher.py +++ /dev/null @@ -1,36 +0,0 @@ -import vergilius - -from consul import tornado, base -from vergilius.models.service import Service - - -class ServiceWatcher(object): - def __init__(self): - self.services = {} - - self.data = {} - self.modified = False - - @tornado.gen.coroutine - def watch_services(self): - index = None - while True: - try: - index, data = yield vergilius.consul_tornado.catalog.services(index, wait=None) - self.check_services(data) - except base.Timeout: - pass - - def check_services(self, data): - # check if service has any of our tags - services_to_publish = dict((k, v) for k, v in data.items() if any(x in v for x in [u'http', u'http2'])) - for service_name in services_to_publish: - if service_name not in self.services: - vergilius.logger.info('[service watcher]: new service: %s' % service_name) - self.services[service_name] = Service(service_name) - - # cleanup stale services - for service_name in self.services.keys(): - if service_name not in services_to_publish.iterkeys(): - vergilius.logger.info('[service watcher]: removing stale service: %s' % service_name) - del self.services[service_name] diff --git a/src/vergilius/models.py b/src/vergilius/models.py new file mode 100644 index 0000000..b84ccf5 --- /dev/null +++ b/src/vergilius/models.py @@ -0,0 +1,407 @@ +import os +import logging +import re +import subprocess +import tempfile +import unicodedata +from datetime import datetime + +import tornado.gen +import tornado.template +from tornado.ioloop import IOLoop +from tornado.locks import Event + +from consul import Consul, ConsulException +from consul.base import Timeout as ConsulTimeout +from consul.tornado import Consul as TornadoConsul + +from vergilius import config + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +logger = logging.getLogger(__name__) +logger.setLevel(config.LOG_LEVEL) + +template_loader = tornado.template.Loader(config.TEMPLATE_PATH) +tc = TornadoConsul(host=config.CONSUL_HOST) +cc = Consul(host=config.CONSUL_HOST) + + +class Service(object): + def __init__(self, name, app): + """ + :type name: unicode - service name got from consul + """ + self.name = name + self.id = self.slugify(name) + logger.info('[service][%s]: new and loading' % self.name) + self.allow_crossdomain = False + self.nodes = {} + self.domains = { + 'http': set(), + 'http2': set() + } + + self.active = True + self.certificate = None + self.app = app + + if not os.path.exists(config.NGINX_CONFIG_PATH): + os.mkdir(config.NGINX_CONFIG_PATH) + + # spawn service watcher + IOLoop.instance().spawn_callback(self.watch) + + @tornado.gen.coroutine + def watch(self): + index = old_nodes = None + while True and self.active: + try: + index, data = yield tc.health.service(self.name, index, wait=None, passing=True) + nodes = sorted([{k: svc[k] for k in svc if k != 'Checks'} for svc in data], + key=lambda x: x['Node']['Node']) + if old_nodes != nodes: + # okay, got data, now parse and reload + yield self.parse_data(data) + yield self.update_config() + old_nodes = nodes + except ConsulTimeout: + pass + except ConsulException as e: + logger.error('consul error: %s' % e) + yield tornado.gen.sleep(5) + + @tornado.gen.coroutine + def parse_data(self, data): + """ + :type data: set[] + """ + for protocol in self.domains.keys(): + self.domains[protocol].clear() + + allow_crossdomain = False + self.nodes = {} + for node in data: + if not node[u'Service'][u'Port']: + logger.warning('[service][%s]: Node %s is ignored due no ServicePort' % (self.id, node[u'Node'])) + continue + + if node[u'Service'][u'Tags'] is None: + logger.warning('[service][%s]: Node %s is ignored due no ServiceTags' % (self.id, node[u'Node'])) + continue + + self.nodes[node['Node']['Node']] = { + 'port': node[u'Service'][u'Port'], + 'address': node[u'Service'][u'Address'] or node[u'Node'][u'Address'], + 'tags': node[u'Service'][u'Tags'], + } + + if u'allow_crossdomain' in node[u'Service'][u'Tags']: + allow_crossdomain = True + + for protocol in [u'http', u'http2']: + if protocol in node[u'Service'][u'Tags']: + self.domains[protocol].update( + tag.replace(protocol + ':', '') for tag in node[u'Service'][u'Tags'] if + tag.startswith(protocol + ':') + ) + + self.allow_crossdomain = allow_crossdomain + + @tornado.gen.coroutine + def update_config(self): + # if we have http2 domain, create stub nginx config for ACME + if self.domains[u'http2']: + + # if we dont have certificate yet, create stub config and wait + if not self.certificate: + logger.debug('[service][%s] flush stub config' % self.id) + self.flush_nginx_config(self.get_stub_config()) + self.certificate = Certificate(service=self, domains=self.domains[u'http2']) + logger.debug('[service][%s] wait for cert' % self.id) + yield self.certificate.ready_event.wait() + logger.debug('[service][%s] load real https config' % self.id) + self.flush_nginx_config(self.get_nginx_config()) + else: + logger.debug('[service][%s] flush real config' % self.id) + self.flush_nginx_config(self.get_nginx_config()) + + def get_nginx_config(self): + """ + Generate nginx config from service attributes + :rtype: bytes + """ + return template_loader.load('service.html').generate(service=self, config=config) + + def get_stub_config(self): + return template_loader.load('service_stub.html').generate(service=self, config=config) + + def flush_nginx_config(self, nginx_config): + if not self.validate(nginx_config): + logger.error('[service][%s]: failed to validate nginx config!' % self.id) + return False + + deployed_nginx_config = None + + try: + deployed_nginx_config = self.read_nginx_config_file() + except IOError: + pass + + if deployed_nginx_config != nginx_config: + config_file = open(self.get_nginx_config_path(), 'wb') + config_file.write(nginx_config) + config_file.close() + logger.info('[service][%s]: got new nginx config %s' % (self.name, self.get_nginx_config_path())) + self.app.nginx_reloader.queue_reload() + + def get_nginx_config_path(self): + return os.path.join(config.NGINX_CONFIG_PATH, self.id + '.conf') + + def read_nginx_config_file(self): + with open(self.get_nginx_config_path(), 'r') as config_file: + config_content = config_file.read() + config_file.close() + return config_content + + def validate(self, config_str): + """ + Deploy temporary service & nginx config and validate it with nginx + :return: bool + """ + service_config_file = tempfile.NamedTemporaryFile(delete=False) + service_config_file.write(config_str) + service_config_file.close() + + nginx_config_file = tempfile.NamedTemporaryFile(delete=False) + nginx_config_file.write(template_loader.load('service_validate.html') + .generate(service_config=service_config_file.name, + pid_file='%s.pid' % service_config_file.name) + ) + nginx_config_file.close() + + result = False + try: + subprocess.run( + [config.NGINX_BINARY, '-t', '-c', nginx_config_file.name], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + check=True + ) + result = True + except subprocess.CalledProcessError as e: + logger.error('[service][%s] nginx config check failed. stderr: ' % self.id, e.stderr) + finally: + os.unlink(service_config_file.name) + os.unlink('%s.pid' % service_config_file.name) + os.unlink(nginx_config_file.name) + return result + + def delete(self): + """ + Destroy service, remove nginx config, stop watcher + """ + + logger.info('[service][%s]: deleting' % self.name) + self.active = False + + try: + os.remove(self.get_nginx_config_path()) + except OSError: + pass + + def __del__(self): + if self.active: + self.delete() + + @classmethod + def slugify(cls, string): + """ + Normalizes string, converts to lowercase, removes non-alpha characters, + and converts spaces to hyphens. + """ + string = unicodedata.normalize('NFKD', string) + string = string.encode('ascii', 'ignore').decode() + string = str(re.sub('[^\w\s-]', '', str(string)).strip().lower()) + return re.sub('[-\s]+', '-', string) + + +class Certificate(object): + def __init__(self, service, domains): + """ + :type domains: set + :type service: Service - service name got from consul + """ + self.ready_event = Event() + self.is_valid = False + self.expires = 0 + self.service = service + self.domains = sorted(domains) + self.key_domains = '' + self.id = '|'.join(self.domains) + + self.private_key = None + self.public_key = None + + self.active = True + self.lock_session_id = None + + self.certificate_provider = self.service.app.certificate_provider + + if not os.path.exists(os.path.join(config.NGINX_CONFIG_PATH, 'certs')): + os.mkdir(os.path.join(config.NGINX_CONFIG_PATH, 'certs')) + + IOLoop.instance().add_callback(self.unlock) + IOLoop.instance().spawn_callback(self.watch) + + @tornado.gen.coroutine + def fetch(self, index): + index, data = yield tc.kv.get('vergilius/certificates/%s/' % self.service.id, index=index, recurse=True) + return index, data + + @tornado.gen.coroutine + def watch(self): + index = None + while True and self.active: + try: + index, data = yield self.fetch(index) + yield self.load_keys_from_consul(data) + except ConsulTimeout: + pass + except ConsulException as e: + logger.error('consul error: %s' % e) + yield tornado.gen.sleep(5) + + @tornado.gen.coroutine + def load_keys_from_consul(self, data=None): + if data: + for item in data: + key = item['Key'].replace('vergilius/certificates/%s/' % self.service.id, '') + if hasattr(self, key): + setattr(self, key, item['Value']) + + if self.validate(): + self.is_valid = True + logger.debug('[certificate][%s]: using existing keys' % self.service.id) + else: + logger.warning('[certificate][%s]: cant validate existing keys' % self.service.id) + self.discard_certificate() + if not (yield self.request_certificate()): + self.ready_event.set() + return False + else: + if not (yield self.request_certificate()): + self.ready_event.set() + return False + + self.write_certificate_files() + self.ready_event.set() + return True + + def write_certificate_files(self): + key_file = open(self.get_key_path(), 'wb') + key_file.write(self.private_key) + key_file.close() + + pem_file = open(self.get_cert_path(), 'wb') + pem_file.write(self.public_key) + pem_file.close() + + def delete_certificate_files(self): + if os.path.exists(self.get_key_path()): + os.remove(self.get_key_path()) + if os.path.exists(self.get_cert_path()): + os.remove(self.get_cert_path()) + + def get_key_path(self): + return os.path.join(config.NGINX_CONFIG_PATH, 'certs', self.service.id + '.key') + + def get_cert_path(self): + return os.path.join(config.NGINX_CONFIG_PATH, 'certs', self.service.id + '.pem') + + @tornado.gen.coroutine + def acquire_lock(self): + """ + Create a lock in consul to prevent certificate request race condition + """ + self.lock_session_id = yield self.service.app.session.get_sid() + result = yield tc.kv.put('vergilius/locks/cert/%s' % self.service.id, '', acquire=self.lock_session_id) + return result + + @tornado.gen.coroutine + def unlock(self): + if not self.lock_session_id: + return + + yield tc.kv.put('vergilius/locks/cert/%s' % self.service.id, '', release=self.lock_session_id) + self.lock_session_id = None + + @tornado.gen.coroutine + def request_certificate(self): + logger.debug('[certificate][%s] Requesting new keys for %s ' % (self.service.name, self.domains)) + + if not (yield self.acquire_lock()): + logger.debug('[certificate][%s] failed to acquire lock for keys generation' % self.service.name) + return False + + try: + data = yield self.certificate_provider.get_certificate(self.domains) + + if data is None: + logger.error('certificate get failed for service %s' % self.service.name) + return False + + self.private_key = data['private_key'] + cc.kv.put('vergilius/certificates/%s/private_key' % self.service.id, self.private_key) + + self.public_key = data['public_key'] + cc.kv.put('vergilius/certificates/%s/public_key' % self.service.id, self.public_key) + + self.expires = data['expires'] + self.key_domains = self.serialize_domains() + logger.debug('write domain %s' % self.key_domains) + cc.kv.put('vergilius/certificates/%s/expires' % self.service.id, str(self.expires)) + cc.kv.put('vergilius/certificates/%s/key_domains' % self.service.id, self.serialize_domains()) + logger.info('[certificate][%s]: got new keys for %s ' % (self.service.name, self.domains)) + self.write_certificate_files() + self.is_valid = True + except Exception as e: + logger.error('[certificate][%s]: certificate request error, discarding: %s' % (self.service.id, e)) + self.is_valid = False + finally: + yield self.unlock() + + def serialize_domains(self): + return '|'.join(sorted(self.domains)).encode() + + def discard_certificate(self): + pass + + def validate(self): + if not self.private_key or not self.public_key: + logger.warning('[certificate][%s]: validation error: empty key' % self.service.id) + return False + + try: + serialization.load_pem_private_key(self.private_key, password=None, backend=default_backend()) + except: + logger.warning('[certificate][%s]: private key load error: expired' % self.service.id, exc_info=True) + return False + + cert = x509.load_pem_x509_certificate(self.public_key, default_backend()) # type: x509.Certificate + if datetime.now() > cert.not_valid_after: + logger.warning('[certificate][%s]: validation error: expired' % self.service.id) + return False + + # TODO: get domain names from cert + if self.key_domains != self.serialize_domains(): + logger.warning('[certificate][%s]: validation error: domains mismatch: %s != %s' % + (self.service.id, self.key_domains, self.serialize_domains())) + return False + + return True + + def __del__(self): + self.active = False + self.delete_certificate_files() diff --git a/src/vergilius/models/__init__.py b/src/vergilius/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/vergilius/models/certificate.py b/src/vergilius/models/certificate.py deleted file mode 100644 index 9e45888..0000000 --- a/src/vergilius/models/certificate.py +++ /dev/null @@ -1,164 +0,0 @@ -import os -import time -from tornado import ioloop - -from consul import tornado, base -from vergilius import consul, logger, certificate_provider, config - - -class Certificate(object): - tc = tornado.Consul(host=config.CONSUL_HOST) - - def __init__(self, service, domains): - """ - :type domains: set - :type service: Service - service name got from consul - """ - self.expires = 0 - self.service = service - self.domains = sorted(domains) - self.key_domains = '' - self.id = '|'.join(self.domains) - - self.private_key = None - self.public_key = None - - self.active = True - self.lock_session_id = None - - if not os.path.exists(os.path.join(config.NGINX_CONFIG_PATH, 'certs')): - os.mkdir(os.path.join(config.NGINX_CONFIG_PATH, 'certs')) - - ioloop.IOLoop.instance().add_callback(self.unlock) - self.fetch() - self.watch() - - def fetch(self): - index, data = consul.kv.get('vergilius/certificates/%s/' % self.service.id, recurse=True) - self.load_keys_from_consul(data) - - @tornado.gen.coroutine - def watch(self): - index = None - while True and self.active: - try: - index, data = \ - yield self.tc.kv.get('vergilius/certificates/%s/' % self.service.id, index=index, recurse=True) - self.load_keys_from_consul(data) - except base.Timeout: - pass - - def load_keys_from_consul(self, data=None): - if data: - for item in data: - key = item['Key'].replace('vergilius/certificates/%s/' % self.service.id, '') - if hasattr(self, key): - setattr(self, key, item['Value']) - - if not self.validate(): - logger.warn('[certificate][%s]: cant validate existing keys' % self.service.id) - self.discard_certificate() - if not self.request_certificate(): - return False - else: - logger.debug('[certificate][%s]: using existing keys' % self.service.id) - else: - if not self.request_certificate(): - return False - - self.write_certificate_files() - return True - - def write_certificate_files(self): - key_file = open(self.get_key_path(), 'w+') - key_file.write(self.private_key) - key_file.close() - - pem_file = open(self.get_cert_path(), 'w+') - pem_file.write(self.public_key) - pem_file.close() - - def delete_certificate_files(self): - if os.path.exists(self.get_key_path()): - os.remove(self.get_key_path()) - if os.path.exists(self.get_cert_path()): - os.remove(self.get_cert_path()) - - def get_key_path(self): - return os.path.join(config.NGINX_CONFIG_PATH, 'certs', self.service.id + '.key') - - def get_cert_path(self): - return os.path.join(config.NGINX_CONFIG_PATH, 'certs', self.service.id + '.pem') - - def lock(self): - """ - Create a lock in consul to prevent certificate request race condition - """ - self.lock_session_id = consul.session.create(behavior='delete') - return consul.kv.put('vergilius/certificates/%s/lock' % self.service.id, '', acquire=self.lock_session_id) - - def unlock(self): - if not self.lock_session_id: - return - - consul.kv.put('vergilius/certificates/%s/lock' % self.service.id, '', release=self.lock_session_id) - consul.session.destroy(self.lock_session_id) - self.lock_session_id = None - - def request_certificate(self): - logger.debug('[certificate][%s] Requesting new keys for %s ' % (self.service.name, self.domains)) - - if not self.lock(): - logger.debug('[certificate][%s] failed to acquire lock for keys generation' % self.service.name) - return False - - try: - data = certificate_provider.get_certificate(self.service.id, self.domains) - - with open(data['private_key'], 'r') as f: - self.private_key = f.read() - f.close() - consul.kv.put('vergilius/certificates/%s/private_key' % self.service.id, self.private_key) - - with open(data['public_key'], 'r') as f: - self.public_key = f.read() - f.close() - consul.kv.put('vergilius/certificates/%s/public_key' % self.service.id, self.public_key) - - self.expires = data['expires'] - self.key_domains = self.serialize_domains() - consul.kv.put('vergilius/certificates/%s/expires' % self.service.id, str(self.expires)) - consul.kv.put('vergilius/certificates/%s/key_domains' % self.service.id, self.serialize_domains()) - logger.info('[certificate][%s]: got new keys for %s ' % (self.service.name, self.domains)) - self.write_certificate_files() - except Exception as e: - logger.error(e) - raise e - finally: - self.unlock() - - def serialize_domains(self): - return '|'.join(sorted(self.domains)) - - def discard_certificate(self): - pass - - def validate(self): - if int(self.expires) < int(time.time()): - logger.warn('[certificate][%s]: validation error: expired' % self.service.id) - return False - - if self.key_domains != self.serialize_domains(): - logger.warn('[certificate][%s]: validation error: domains mismatch: %s != %s' % - (self.service.id, self.key_domains, self.serialize_domains())) - return False - - if not len(self.private_key) or not len(self.public_key): - logger.warn('[certificate][%s]: validation error: empty key' % self.service.id) - return False - - return True - - def __del__(self): - self.active = False - self.delete_certificate_files() diff --git a/src/vergilius/models/identity.py b/src/vergilius/models/identity.py deleted file mode 100644 index 7211603..0000000 --- a/src/vergilius/models/identity.py +++ /dev/null @@ -1,85 +0,0 @@ -import os -import subprocess -import tempfile - -import vergilius - -IDENTITY_PATH = os.path.join(vergilius.config.DATA_PATH, 'identity') - - -def openssl(*args): - cmdline = [vergilius.config.OPENSSL_BINARY] + list(args) - subprocess.check_call(cmdline) - - -class Identity(object): - """ - Stores private keys, certificate in consul. - Generates new identity if not specified - """ - - def __init__(self): - self.write_files() - - def get_private_key(self): - index, data = vergilius.consul.kv.get('vergilius/identity/private_key') - if not data: - return False - else: - return data['Value'] - - def get_private_key_path(self): - return os.path.join(IDENTITY_PATH, 'identity.key') - - def get_certificate(self): - index, data = vergilius.consul.kv.get('vergilius/identity/certificate') - if not data: - self.generate_certificate() - return self.get_certificate() - else: - return data['Value'] - - def get_certificate_path(self): - return os.path.join(IDENTITY_PATH, 'identity.crt') - - def generate_identity(self): - vergilius.logger.info('[identity]: generating new identity') - - openssl('genrsa', '-des3', '-passout', 'pass:%s' % vergilius.config.SECRET, '-out', self.get_private_key_path(), - '4096') - - private_key_file = open(self.get_private_key_path(), 'r') - vergilius.consul.kv.put('vergilius/identity/private_key', private_key_file.read()) - private_key_file.close() - - self.get_certificate() - - def generate_certificate(self): - ssl_config_file = tempfile.NamedTemporaryFile(delete=False) - ssl_config_file.write(vergilius.template_loader.load('identity.html').generate( - name='vergilius', email=vergilius.config.EMAIL - )) - ssl_config_file.close() - - openssl('req', '-new', '-x509', '-days', '3650', '-key', self.get_private_key_path(), '-out', - self.get_certificate_path(), '-passin', 'pass:%s' % vergilius.config.SECRET, '-config', - ssl_config_file.name) - - certificate_file = open(self.get_certificate_path(), 'r') - vergilius.consul.kv.put('vergilius/identity/certificate', certificate_file.read()) - certificate_file.close() - - def write_files(self): - if not os.path.exists(IDENTITY_PATH): - os.mkdir(IDENTITY_PATH) - - if not self.get_private_key(): - self.generate_identity() - - private_key_file = open(self.get_private_key_path(), 'w+') - private_key_file.write(self.get_private_key()) - private_key_file.close() - - certificate_file = open(self.get_certificate_path(), 'w+') - certificate_file.write(self.get_certificate()) - certificate_file.close() diff --git a/src/vergilius/models/service.py b/src/vergilius/models/service.py deleted file mode 100644 index 2e8afcf..0000000 --- a/src/vergilius/models/service.py +++ /dev/null @@ -1,185 +0,0 @@ -import os -import re -import subprocess -import tempfile -import unicodedata - -from consul import tornado, base, ConsulException -from vergilius import config, consul_tornado, consul, logger, template_loader -from vergilius.loop.nginx_reloader import NginxReloader -from vergilius.models.certificate import Certificate - - -class Service(object): - def __init__(self, name): - """ - :type name: unicode - service name got from consul - """ - self.name = name - self.id = self.slugify(name) - logger.info('[service][%s]: new and loading' % self.name) - self.allow_crossdomain = False - self.nodes = {} - self.domains = { - u'http': set(), - u'http2': set() - } - - self.active = True - self.certificate = None - - if not os.path.exists(config.NGINX_CONFIG_PATH): - os.mkdir(config.NGINX_CONFIG_PATH) - - self.fetch() - self.watch() - - def fetch(self): - index, data = consul.health.service(self.name, passing=True) - self.parse_data(data) - - @tornado.gen.coroutine - def watch(self): - index = None - while True and self.active: - try: - index, data = yield consul_tornado.health.service(self.name, index, wait=None, passing=True) - self.parse_data(data) - except ConsulException as e: - logger.error('consul exception: %s' % e) - except base.Timeout: - pass - - def parse_data(self, data): - """ - - :type data: set[] - """ - for protocol in self.domains.iterkeys(): - self.domains[protocol].clear() - - allow_crossdomain = False - self.nodes = {} - for node in data: - if not node[u'Service'][u'Port']: - logger.warn('[service][%s]: Node %s is ignored due no ServicePort' % (self.id, node[u'Node'])) - continue - - if node[u'Service'][u'Tags'] is None: - logger.warn('[service][%s]: Node %s is ignored due no ServiceTags' % (self.id, node[u'Node'])) - continue - - self.nodes[node['Node']['Node']] = { - 'port': node[u'Service'][u'Port'], - 'address': node[u'Service'][u'Address'] or node[u'Node'][u'Address'], - 'tags': node[u'Service'][u'Tags'], - } - - if u'allow_crossdomain' in node[u'Service'][u'Tags']: - allow_crossdomain = True - - for protocol in [u'http', u'http2']: - if protocol in node[u'Service'][u'Tags']: - self.domains[protocol].update( - tag.replace(protocol + ':', '') for tag in node[u'Service'][u'Tags'] if - tag.startswith(protocol + ':') - ) - - self.allow_crossdomain = allow_crossdomain - - self.flush_nginx_config() - - def get_nginx_config(self): - """ - Generate nginx config from service attributes - """ - if self.domains[u'http2']: - self.check_certificate() - return template_loader.load('service.html').generate(service=self, config=config) - - def flush_nginx_config(self): - if not self.validate(): - logger.error('[service][%s]: failed to validate nginx config!' % self.id) - return False - - nginx_config = self.get_nginx_config() - deployed_nginx_config = None - - try: - deployed_nginx_config = self.read_nginx_config_file() - except IOError: - pass - - if deployed_nginx_config != nginx_config: - config_file = open(self.get_nginx_config_path(), 'w+') - config_file.write(nginx_config) - config_file.close() - logger.info('[service][%s]: got new nginx config %s' % (self.name, self.get_nginx_config_path())) - NginxReloader.queue_reload() - - def get_nginx_config_path(self): - return os.path.join(config.NGINX_CONFIG_PATH, self.id + '.conf') - - def read_nginx_config_file(self): - with open(self.get_nginx_config_path(), 'r') as config_file: - config_content = config_file.read() - config_file.close() - return config_content - - def validate(self): - """ - Deploy temporary service & nginx config and validate it with nginx - :return: bool - """ - service_config_file = tempfile.NamedTemporaryFile(delete=False) - service_config_file.write(self.get_nginx_config()) - service_config_file.close() - - nginx_config_file = tempfile.NamedTemporaryFile(delete=False) - nginx_config_file.write(template_loader.load('service_validate.html') - .generate(service_config=service_config_file.name, - pid_file='%s.pid' % service_config_file.name) - ) - nginx_config_file.close() - - try: - return_code = subprocess.check_call([config.NGINX_BINARY, '-t', '-c', nginx_config_file.name]) - except subprocess.CalledProcessError: - return_code = 1 - finally: - os.unlink(service_config_file.name) - os.unlink('%s.pid' % service_config_file.name) - os.unlink(nginx_config_file.name) - - return return_code == 0 - - def delete(self): - """ - Destroy service, remove nginx config, stop watcher - """ - - logger.info('[service][%s]: deleting' % self.name) - self.active = False - - try: - os.remove(self.get_nginx_config_path()) - except OSError: - pass - - def __del__(self): - if self.active: - self.delete() - - @classmethod - def slugify(cls, string): - """ - Normalizes string, converts to lowercase, removes non-alpha characters, - and converts spaces to hyphens. - """ - string = unicodedata.normalize('NFKD', unicode(string)).encode('ascii', 'ignore') - string = unicode(re.sub('[^\w\s-]', '', string).strip().lower()) - return re.sub('[-\s]+', '-', string) - - def check_certificate(self): - if not self.certificate: - self.certificate = Certificate(service=self, domains=self.domains[u'http2']) diff --git a/src/vergilius/templates/service.html b/src/vergilius/templates/service.html index 63adc26..907c9c2 100644 --- a/src/vergilius/templates/service.html +++ b/src/vergilius/templates/service.html @@ -6,7 +6,7 @@ {% if not service.nodes %}server 127.0.0.1:6666;{% end %} } -{% if len(service.domains['http2']) and service.certificate.private_key and service.certificate.public_key %} +{% if len(service.domains['http2']) and service.certificate.is_valid %} server { server_name{% for domain in service.domains['http2'] %} {{ domain }} *.{{ domain }}{% end %}; listen {{config.NGINX_HTTP2_PORT}} ssl http2; @@ -68,5 +68,9 @@ return 301 https://$server_name$request_uri; } {% end %} + + location /.well-known/ { + proxy_pass http://127.0.0.1:8888; + } } {% end %} \ No newline at end of file diff --git a/src/vergilius/templates/service_stub.html b/src/vergilius/templates/service_stub.html new file mode 100644 index 0000000..f77421d --- /dev/null +++ b/src/vergilius/templates/service_stub.html @@ -0,0 +1,15 @@ +{% whitespace all%} +{% if len(service.domains['http2']) %} +server { + server_name{% for domain in service.domains['http2'] %} {{ domain }} *.{{ domain }}{% end %}; + listen {{config.NGINX_HTTP_PORT}}; + + location / { + return 503; + } + + location /.well-known/ { + proxy_pass http://127.0.0.1:8888; + } +} +{% end %} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/base_test.py b/tests/base_test.py index 9ecca6e..d7ef039 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -1,46 +1,42 @@ import logging import os -import threading import unittest import sys import shutil -import tornado +import tornado.ioloop +import tornado.testing -import vergilius -from vergilius import consul, logger -from vergilius.loop.service_watcher import ServiceWatcher -from vergilius.models.identity import Identity +import vergilius.base +import vergilius.cert +import vergilius.loop +import vergilius.config + +from consul import Consul out_hdlr = logging.StreamHandler(sys.stdout) out_hdlr.setFormatter(logging.Formatter('%(asctime)s %(message)s')) out_hdlr.setLevel(logging.DEBUG) +logger = logging.getLogger('tests') logger.addHandler(out_hdlr) logger.setLevel(logging.DEBUG) +cc = Consul() -def start_tornado(): - tornado.ioloop.IOLoop.instance().start() - - -class BaseTest(unittest.TestCase): - @classmethod - def setUpClass(cls): - super(BaseTest, cls).setUpClass() - cls.watcher = ServiceWatcher() - cls.watcher.watch_services() - threading.Thread(target=start_tornado).start() +class MockApp(object): + def __init__(self): + self.session = vergilius.base.ConsulSession() + self.certificate_provider = vergilius.cert.DummyCertificateProvider() + self.nginx_reloader = vergilius.loop.NginxReloader() - @classmethod - def tearDownClass(cls): - super(BaseTest, cls).tearDownClass() - tornado.ioloop.IOLoop.instance().stop() +class BaseAsyncTest(tornado.testing.AsyncTestCase): def setUp(self): - super(BaseTest, self).setUp() - consul.kv.delete('vergilius', True) + super(BaseAsyncTest, self).setUp() + self.app = MockApp() + cc.kv.delete('vergilius', True) try: os.mkdir(vergilius.config.DATA_PATH) @@ -48,11 +44,10 @@ def setUp(self): except OSError as e: print(e) - vergilius.Vergilius.init() - def tearDown(self): - super(BaseTest, self).tearDown() - consul.kv.delete('vergilius', True) - + super(BaseAsyncTest, self).tearDown() shutil.rmtree(vergilius.config.NGINX_CONFIG_PATH) shutil.rmtree(vergilius.config.DATA_PATH) + + def get_new_ioloop(self): + return tornado.ioloop.IOLoop.instance() \ No newline at end of file diff --git a/tests/test_cert.py b/tests/test_cert.py new file mode 100644 index 0000000..d5cbbe5 --- /dev/null +++ b/tests/test_cert.py @@ -0,0 +1,8 @@ +import unittest +from vergilius.cert import DummyCertificateProvider + + +class DummyCertificateProviderTest(unittest.TestCase): + def test_base(self): + provider = DummyCertificateProvider() + provider.get_certificate(domains={'example.com', 'foo.example.com'}) diff --git a/tests/test_certificate.py b/tests/test_certificate.py deleted file mode 100644 index 710fe4c..0000000 --- a/tests/test_certificate.py +++ /dev/null @@ -1,24 +0,0 @@ -from mock import mock - -from base_test import BaseTest -from vergilius import consul -from vergilius.models.certificate import Certificate -from vergilius.models.service import Service - - -class Test(BaseTest): - def __init__(self, methodName='runTest'): - super(Test, self).__init__(methodName) - self.service = Service('test') - - def setUp(self): - super(Test, self).setUp() - consul.kv.delete('vergilius', True) - - def test_keys_request(self): - cert = Certificate(service=self.service, domains={'example.com'}) - self.assertTrue(cert.validate(), 'got valid keys') - - with mock.patch.object(Certificate, 'request_certificate', return_value={}) as mock_method: - Certificate(service=self.service, domains={'example.com'}) - self.assertFalse(mock_method.called, 'check if existing keys are not requested from provider') diff --git a/tests/test_dummy_certificate_provider.py b/tests/test_dummy_certificate_provider.py deleted file mode 100644 index 8f5d635..0000000 --- a/tests/test_dummy_certificate_provider.py +++ /dev/null @@ -1,9 +0,0 @@ -from base_test import BaseTest -from vergilius import DummyCertificateProvider - -provider = DummyCertificateProvider() - - -class Test(BaseTest): - def test_base(self): - provider.get_certificate(id='example.com|foo.example.com', domains={'example.com', 'foo.example.com'}) diff --git a/tests/test_loop.py b/tests/test_loop.py new file mode 100644 index 0000000..6dba276 --- /dev/null +++ b/tests/test_loop.py @@ -0,0 +1,29 @@ +import tornado.testing +from base_test import BaseAsyncTest, cc +import vergilius.loop + + +class ServiceWatcherTest(BaseAsyncTest): + def setUp(self): + super().setUp() + self.watcher = vergilius.loop.ServiceWatcher(self.app) + + def tearDown(self): + super(ServiceWatcherTest, self).tearDown() + cc.agent.service.deregister('test') + + @tornado.testing.gen_test + def test_poll(self): + cc.agent.service.register('test', 'test', tags=['http'], port=80) + yield self.watcher.fetch_services() + self.assertTrue('test' in self.watcher.services, 'service registered') + + cc.agent.service.deregister('test') + yield self.watcher.fetch_services() + self.assertFalse('test' in self.watcher.services.keys(), 'service unregistered') + + @tornado.testing.gen_test + def test_empty_service(self): + cc.agent.service.register('test', 'test') + yield self.watcher.fetch_services() + self.assertFalse('test' in self.watcher.services, 'service not registered') diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..8b357f6 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,67 @@ +import tornado.testing +from base_test import BaseAsyncTest, cc +from vergilius.models import Service, Certificate +from mock import mock + + +class CertificateTest(BaseAsyncTest): + def setUp(self): + super().setUp() + cc.kv.delete('vergilius', True) + + @tornado.testing.gen_test + def test_keys_request(self): + service = Service('test', app=self.app) + cert = Certificate(service, domains={'example.com'}) + yield cert.ready_event.wait() + self.assertTrue(cert.validate(), 'got valid keys') + + with mock.patch.object(Certificate, 'request_certificate', return_value={}) as mock_method: + Certificate(service=service, domains={'example.com'}) + self.assertFalse(mock_method.called, 'check if existing keys are not requested from provider') + + +class ServiceTest(BaseAsyncTest): + def setUp(self): + super(ServiceTest, self).setUp() + cc.kv.delete('vergilius', True) + + def test_base(self): + service = Service(name='test service', app=self.app) + service.flush_nginx_config(service.get_nginx_config()) + + config_file = service.get_nginx_config_path() + self.assertNotEqual(service.read_nginx_config_file().find('server 127.0.0.1:6666'), -1, + 'config written and has backup 503') + self.assertTrue(service.validate(service.get_nginx_config()), 'nginx config is valid') + service.delete() + + with self.assertRaises(IOError): + open(config_file, 'r') + + def test_http(self): + service = Service(name='test service', app=self.app) + + service.domains[u'http'] = ('example.com',) + + self.assertNotEqual(service.get_nginx_config().decode().find('server_name example.com *.example.com;'), -1, + 'server_name and wildcard present') + self.assertTrue(service.validate(service.get_nginx_config()), 'nginx config is valid') + + @tornado.testing.gen_test + def test_http2(self): + service = Service(name='test service', app=self.app) + service.domains[u'http2'] = ('example.com',) + service.certificate = Certificate(service, service.domains) + yield service.certificate.ready_event.wait() + self.assertTrue(service.validate(service.get_nginx_config()), 'nginx config is valid') + + def test_upstream_nodes(self): + service = Service(name='test service', app=self.app) + service.domains[u'http'] = ('example.com',) + service.nodes['test_node'] = {'address': '127.0.0.1', 'port': '10000'} + self.assertTrue(service.validate(service.get_nginx_config()), 'nginx config is valid') + + config = service.get_nginx_config().decode() + self.assertNotEqual(config.find('server 127.0.0.1:10000;'), -1, 'upstream node present') + self.assertEqual(config.find('server 127.0.0.1:6666'), -1, 'backup node deleted') diff --git a/tests/test_service.py b/tests/test_service.py deleted file mode 100644 index 19ccff1..0000000 --- a/tests/test_service.py +++ /dev/null @@ -1,50 +0,0 @@ - -from base_test import BaseTest -from vergilius import consul -from vergilius.models.service import Service - - -class Test(BaseTest): - def setUp(self): - super(Test, self).setUp() - consul.kv.delete('vergilius', True) - - def test_watcher(self): - pass - - def test_base(self): - service = Service(name='test service') - service.flush_nginx_config() - - config_file = service.get_nginx_config_path() - self.assertNotEqual(service.read_nginx_config_file().find('server 127.0.0.1:6666'), -1, - 'config written and has backup 503') - self.assertTrue(service.validate(), 'nginx config is valid') - service.delete() - - with self.assertRaises(IOError): - open(config_file, 'r') - - def test_http(self): - service = Service(name='test service') - - service.domains[u'http'] = ('example.com',) - - self.assertNotEqual(service.get_nginx_config().find('server_name example.com *.example.com;'), -1, - 'server_name and wildcard present') - self.assertTrue(service.validate(), 'nginx config is valid') - - def test_http2(self): - service = Service(name='test service') - service.domains[u'http2'] = ('example.com',) - self.assertTrue(service.validate(), 'nginx config is valid') - - def test_upstream_nodes(self): - service = Service(name='test service') - service.domains[u'http'] = ('example.com',) - service.nodes['test_node'] = {'address': '127.0.0.1', 'port': '10000'} - self.assertTrue(service.validate(), 'nginx config is valid') - - config = service.get_nginx_config() - self.assertNotEqual(config.find('server 127.0.0.1:10000;'), -1, 'upstream node present') - self.assertEqual(config.find('server 127.0.0.1:6666'), -1, 'backup node deleted') diff --git a/tests/test_service_watcher.py b/tests/test_service_watcher.py deleted file mode 100644 index 2fb4107..0000000 --- a/tests/test_service_watcher.py +++ /dev/null @@ -1,23 +0,0 @@ -import time - -from base_test import BaseTest -from vergilius import consul - - -class Test(BaseTest): - def test_poll(self): - consul.agent.service.register('test', 'test', tags=['http'], port=80) - time.sleep(2) - self.assertTrue('test' in self.watcher.services, 'service registered') - consul.agent.service.deregister('test') - time.sleep(1) - self.assertFalse('test' in self.watcher.services.keys(), 'service unregistered') - - def test_empty_service(self): - consul.agent.service.register('test', 'test') - - time.sleep(2) - self.assertFalse('test' in self.watcher.services, 'service not registered') - - def tearDown(self): - consul.agent.service.deregister('test')