Skip to content

Commit 78e4998

Browse files
author
Matthew Fisher
committed
Merge pull request #2911 from bacongobbler/18-ssl-proposal
Proposal: SSL support for custom domains
2 parents 7367202 + 28ad7a3 commit 78e4998

37 files changed

Lines changed: 2197 additions & 117 deletions

client/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
build: setup-venv
3-
venv/bin/pip install docopt==0.6.2 python-dateutil==2.4.1 PyYAML==3.11 requests==2.5.1 git+https://github.com/pyinstaller/pyinstaller@7413317 termcolor==1.1.0
3+
venv/bin/pip install docopt==0.6.2 python-dateutil==2.4.1 PyYAML==3.11 requests==2.5.1 git+https://github.com/pyinstaller/pyinstaller@7413317 tabulate==0.7.4 termcolor==1.1.0
44
venv/bin/pyinstaller deis.spec
55
chmod +x dist/deis
66

client/deis.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
limits manage resource limits for your application
2121
tags manage tags for application containers
2222
releases manage releases of an application
23+
certs manage SSL endpoints for an app
2324
2425
keys manage ssh keys used for `git push` deployments
2526
perms manage permissions for applications
@@ -68,12 +69,13 @@
6869
from docopt import docopt
6970
from docopt import DocoptExit
7071
import requests
72+
from tabulate import tabulate
7173
from termcolor import colored
7274

7375
__version__ = '1.5.0-dev'
7476

7577
# what version of the API is this client compatible with?
76-
__api_version__ = '1.1'
78+
__api_version__ = '1.2'
7779

7880

7981
locale.setlocale(locale.LC_ALL, '')
@@ -977,6 +979,94 @@ def builds_list(self, args):
977979
else:
978980
raise ResponseError(response)
979981

982+
def certs(self, args):
983+
"""
984+
Valid commands for certs:
985+
986+
certs:list list SSL certificates for an app
987+
certs:add add an SSL certificate to an app
988+
certs:update update an existing certifcate for an app
989+
certs:remove remove an SSL certificate from an app
990+
991+
Use `deis help [command]` to learn more.
992+
"""
993+
sys.argv[1] = 'certs:list'
994+
args = docopt(self.certs_list.__doc__)
995+
return self.certs_list(args)
996+
997+
def certs_add(self, args):
998+
"""
999+
Binds a certificate/key pair to an application.
1000+
1001+
Usage: deis certs:add <cert> <key>
1002+
1003+
Arguments:
1004+
<cert>
1005+
The public key of the SSL certificate.
1006+
<key>
1007+
The private key of the SSL certificate.
1008+
"""
1009+
cert = args.get('<cert>')
1010+
key = args.get('<key>')
1011+
body = {'certificate': file(cert).read().strip(), 'key': file(key).read().strip()}
1012+
sys.stdout.write("Adding SSL endpoint... ")
1013+
sys.stdout.flush()
1014+
try:
1015+
progress = TextProgress()
1016+
progress.start()
1017+
response = self._dispatch('post', "/v1/certs", json.dumps(body))
1018+
finally:
1019+
progress.cancel()
1020+
progress.join()
1021+
if response.status_code == requests.codes.created:
1022+
self._logger.info("done")
1023+
data = response.json()
1024+
self._logger.info("{common_name}".format(**data))
1025+
else:
1026+
raise ResponseError(response)
1027+
1028+
def certs_list(self, args):
1029+
"""
1030+
Show certificate information for an SSL application.
1031+
1032+
Usage: deis certs:list
1033+
"""
1034+
response = self._dispatch('get', "/v1/certs")
1035+
if response.status_code == requests.codes.ok:
1036+
data = response.json()
1037+
table = [['Common Name', 'Expires']]
1038+
if len(data['results']) == 0:
1039+
self._logger.info('No certs')
1040+
return
1041+
for item in data['results']:
1042+
# strip unused fields
1043+
for field in item.keys():
1044+
if field not in ['common_name', 'expires']:
1045+
del item[field]
1046+
table += [[item['common_name'], item['expires']]]
1047+
self._logger.info(tabulate(table, headers='firstrow'))
1048+
else:
1049+
raise ResponseError(response)
1050+
1051+
def certs_remove(self, args):
1052+
"""
1053+
removes a certificate/key pair from the application.
1054+
1055+
Usage: deis certs:remove <cn> [options]
1056+
1057+
Arguments:
1058+
<cn>
1059+
the common name of the cert to remove from the app.
1060+
"""
1061+
cn = args.get('<cn>')
1062+
sys.stdout.write("Removing {}... ".format(cn))
1063+
sys.stdout.flush()
1064+
response = self._dispatch('delete', "/v1/certs/{}".format(cn))
1065+
if response.status_code == requests.codes.no_content:
1066+
self._logger.info('Done.')
1067+
else:
1068+
raise ResponseError(response)
1069+
9801070
def config(self, args):
9811071
"""
9821072
Valid commands for config:

client/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
install_requires=[
6060
'docopt==0.6.2', 'python-dateutil==2.4.1',
6161
'PyYAML==3.11', 'requests==2.5.1',
62-
'termcolor==1.1.0'
62+
'tabulate==0.7.4', 'termcolor==1.1.0'
6363
],
6464
zip_safe=True,
6565
**KWARGS)

contrib/ec2/deis.template.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,12 @@
297297
"LoadBalancerPort": "80",
298298
"Protocol": "HTTP"
299299
},
300+
{
301+
"InstancePort": "443",
302+
"InstanceProtocol": "TCP",
303+
"LoadBalancerPort": "443",
304+
"Protocol": "TCP"
305+
},
300306
{
301307
"InstancePort": "2222",
302308
"InstanceProtocol": "TCP",
@@ -320,6 +326,7 @@
320326
"GroupDescription": "Deis Web ELB SecurityGroup",
321327
"SecurityGroupIngress": [
322328
{"IpProtocol": "tcp", "FromPort": "80", "ToPort": "80", "CidrIp": "0.0.0.0/0"},
329+
{"IpProtocol": "tcp", "FromPort": "443", "ToPort": "443", "CidrIp": "0.0.0.0/0"},
323330
{"IpProtocol": "tcp", "FromPort": "2222", "ToPort": "2222", "CidrIp": "0.0.0.0/0"}
324331
],
325332
"VpcId": { "Ref" : "VPC" }
@@ -332,6 +339,7 @@
332339
"SecurityGroupIngress" : [
333340
{"IpProtocol": "tcp", "FromPort" : "22", "ToPort" : "22", "CidrIp" : { "Ref" : "SSHFrom" }},
334341
{"IpProtocol": "tcp", "FromPort": "80", "ToPort": "80", "SourceSecurityGroupId": { "Ref": "DeisWebELBSecurityGroup" } },
342+
{"IpProtocol": "tcp", "FromPort": "443", "ToPort": "443", "SourceSecurityGroupId": { "Ref": "DeisWebELBSecurityGroup" } },
335343
{"IpProtocol": "tcp", "FromPort": "2222", "ToPort": "2222", "SourceSecurityGroupId": { "Ref": "DeisWebELBSecurityGroup" } }
336344
],
337345
"VpcId" : { "Ref" : "VPC" }

controller/api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
The **api** Django app presents a RESTful web API for interacting with the **deis** system.
33
"""
44

5-
__version__ = '1.1.2'
5+
__version__ = '1.2.0'

controller/api/models.py

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import unicode_literals
88
import base64
9+
from datetime import datetime
910
import etcd
1011
import importlib
1112
import logging
@@ -17,7 +18,7 @@
1718

1819
from django.conf import settings
1920
from django.contrib.auth import get_user_model
20-
from django.core.exceptions import ValidationError
21+
from django.core.exceptions import ValidationError, SuspiciousOperation
2122
from django.db import models
2223
from django.db.models import Count
2324
from django.db.models import Max
@@ -26,6 +27,7 @@
2627
from django.utils.encoding import python_2_unicode_compatible
2728
from docker.utils import utils as dockerutils
2829
from json_field.fields import JSONField
30+
from OpenSSL import crypto
2931
import requests
3032
from rest_framework.authtoken.models import Token
3133

@@ -110,6 +112,13 @@ def validate_domain(value):
110112
raise ValidationError('"{}" contains unexpected characters'.format(value))
111113

112114

115+
def validate_certificate(value):
116+
try:
117+
crypto.load_certificate(crypto.FILETYPE_PEM, value)
118+
except crypto.Error as e:
119+
raise ValidationError('Could not load certificate: {}'.format(e))
120+
121+
113122
class AuditedModel(models.Model):
114123
"""Add created and updated fields to a model."""
115124

@@ -825,6 +834,38 @@ def __str__(self):
825834
return self.domain
826835

827836

837+
@python_2_unicode_compatible
838+
class Certificate(AuditedModel):
839+
"""
840+
Public and private key pair used to secure application traffic at the router.
841+
"""
842+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
843+
# there is no upper limit on the size of an x.509 certificate
844+
certificate = models.TextField(validators=[validate_certificate])
845+
key = models.TextField()
846+
# X.509 certificates allow any string of information as the common name.
847+
common_name = models.TextField(unique=True)
848+
expires = models.DateTimeField()
849+
850+
def __str__(self):
851+
return self.common_name
852+
853+
def _get_certificate(self):
854+
try:
855+
return crypto.load_certificate(crypto.FILETYPE_PEM, self.certificate)
856+
except crypto.Error as e:
857+
raise SuspiciousOperation(e)
858+
859+
def save(self, *args, **kwargs):
860+
certificate = self._get_certificate()
861+
if not self.common_name:
862+
self.common_name = certificate.get_subject().CN
863+
if not self.expires:
864+
# convert openssl's expiry date format to Django's DateTimeField format
865+
self.expires = datetime.strptime(certificate.get_notAfter(), '%Y%m%d%H%M%SZ')
866+
return super(Certificate, self).save(*args, **kwargs)
867+
868+
828869
@python_2_unicode_compatible
829870
class Key(UuidAuditedModel):
830871
"""An SSH public key."""
@@ -878,6 +919,16 @@ def _log_domain_removed(**kwargs):
878919
log_event(domain.app, msg)
879920

880921

922+
def _log_cert_added(**kwargs):
923+
cert = kwargs['instance']
924+
logger.info("cert {} added".format(cert))
925+
926+
927+
def _log_cert_removed(**kwargs):
928+
cert = kwargs['instance']
929+
logger.info("cert {} removed".format(cert))
930+
931+
881932
def _etcd_publish_key(**kwargs):
882933
key = kwargs['instance']
883934
_etcd_client.write('/deis/builder/users/{}/{}'.format(
@@ -917,33 +968,45 @@ def _etcd_purge_app(**kwargs):
917968
pass
918969

919970

971+
def _etcd_publish_cert(**kwargs):
972+
cert = kwargs['instance']
973+
if kwargs['created']:
974+
_etcd_client.write('/deis/certs/{}/cert'.format(cert), cert.certificate)
975+
_etcd_client.write('/deis/certs/{}/key'.format(cert), cert.key)
976+
977+
978+
def _etcd_purge_cert(**kwargs):
979+
cert = kwargs['instance']
980+
try:
981+
_etcd_client.delete('/deis/certs/{}'.format(cert),
982+
prevExist=True, dir=True, recursive=True)
983+
except KeyError:
984+
pass
985+
986+
920987
def _etcd_publish_domains(**kwargs):
921-
app = kwargs['instance'].app
922-
app_domains = app.domain_set.all()
923-
if app_domains:
924-
_etcd_client.write('/deis/domains/{}'.format(app),
925-
' '.join(str(d.domain) for d in app_domains))
988+
domain = kwargs['instance']
989+
if kwargs['created']:
990+
_etcd_client.write('/deis/domains/{}'.format(domain), domain.app)
926991

927992

928993
def _etcd_purge_domains(**kwargs):
929-
app = kwargs['instance'].app
930-
app_domains = app.domain_set.all()
931-
if app_domains:
932-
_etcd_client.write('/deis/domains/{}'.format(app),
933-
' '.join(str(d.domain) for d in app_domains))
934-
else:
935-
try:
936-
_etcd_client.delete('/deis/domains/{}'.format(app))
937-
except KeyError:
938-
pass
994+
domain = kwargs['instance']
995+
try:
996+
_etcd_client.delete('/deis/certs/{}'.format(domain),
997+
prevExist=True, dir=True, recursive=True)
998+
except KeyError:
999+
pass
9391000

9401001

9411002
# Log significant app-related events
9421003
post_save.connect(_log_build_created, sender=Build, dispatch_uid='api.models.log')
9431004
post_save.connect(_log_release_created, sender=Release, dispatch_uid='api.models.log')
9441005
post_save.connect(_log_config_updated, sender=Config, dispatch_uid='api.models.log')
9451006
post_save.connect(_log_domain_added, sender=Domain, dispatch_uid='api.models.log')
1007+
post_save.connect(_log_cert_added, sender=Certificate, dispatch_uid='api.models.log')
9461008
post_delete.connect(_log_domain_removed, sender=Domain, dispatch_uid='api.models.log')
1009+
post_delete.connect(_log_cert_removed, sender=Certificate, dispatch_uid='api.models.log')
9471010

9481011

9491012
# automatically generate a new token on creation
@@ -968,3 +1031,5 @@ def create_auth_token(sender, instance=None, created=False, **kwargs):
9681031
post_delete.connect(_etcd_purge_domains, sender=Domain, dispatch_uid='api.models')
9691032
post_save.connect(_etcd_create_app, sender=App, dispatch_uid='api.models')
9701033
post_delete.connect(_etcd_purge_app, sender=App, dispatch_uid='api.models')
1034+
post_save.connect(_etcd_publish_cert, sender=Certificate, dispatch_uid='api.models')
1035+
post_delete.connect(_etcd_purge_cert, sender=Certificate, dispatch_uid='api.models')

controller/api/serializers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ def validate_domain(self, value):
245245
if value[-1:] == ".":
246246
value = value[:-1] # strip exactly one dot from the right, if present
247247
labels = value.split('.')
248+
if 'xip.io' in value:
249+
return value
248250
if labels[0] == '*':
249251
raise serializers.ValidationError(
250252
'Adding a wildcard subdomain is currently not supported.')
@@ -260,6 +262,22 @@ def validate_domain(self, value):
260262
return value
261263

262264

265+
class CertificateSerializer(ModelSerializer):
266+
"""Serialize a :class:`~api.models.Cert` model."""
267+
268+
owner = serializers.ReadOnlyField(source='owner.username')
269+
expires = serializers.DateTimeField(format=settings.DEIS_DATETIME_FORMAT, read_only=True)
270+
created = serializers.DateTimeField(format=settings.DEIS_DATETIME_FORMAT, read_only=True)
271+
updated = serializers.DateTimeField(format=settings.DEIS_DATETIME_FORMAT, read_only=True)
272+
273+
class Meta:
274+
"""Metadata options for a DomainCertSerializer."""
275+
model = models.Certificate
276+
extra_kwargs = {'certificate': {'write_only': True},
277+
'key': {'write_only': True}}
278+
read_only_fields = ['common_name', 'expires', 'created', 'updated']
279+
280+
263281
class PushSerializer(ModelSerializer):
264282
"""Serialize a :class:`~api.models.Push` model."""
265283

0 commit comments

Comments
 (0)