Repository: deis/deis

Issue:
deis blocks domain creation under improper circumstances
`$ deis domains:add django.paas-sandbox`
`Adding django.paas-sandbox to rubber-undertow... 400 BAD REQUEST`
`{u'domain': [u'Hostname does not look like a valid hostname. Only lowercase characters are allowed.']}`

deis will not let me create a domain where the top of the domain is "paas-sandbox", as displayed above, however this is not a top level domain nor is it intended as one. Our corporation uses an internal domain that is set as the dns search on all corporate computers. For example, if someone types `http://www/` in their browser, it will come up because their dns searches `corp.example.com` for non qualifying domains. They are really going to the site `www.corp.example.com` but all they type and all the host header on the server sees is "www". deis requires the domain to exist how the user will display it in the browser in order to render the page. Our DNS system is almost identical to Active Directory so anyone using Active Directory can relate but this is not a MS / Active Directory specific scenario. This can be done using only dhcp and most ISP's will do this from their dhcp as well.

Anyways, please fix. Thank you.



Discussion:
The error is misleading, but the domain needs to be fully qualifiable, i.e. `django.paas-sandbox.com`. I'm not quite sure how we can fit in non-qualifying domains without breaking validation against regular domain names.

The regular expression in question is here: https://github.com/deis/deis/blob/master/controller/api/serializers.py#L242-L248

> the domain needs to be fully qualifiable

Why?

My point is that it should not require the domain to be fully qualifiable.
 deis is aimed at private PaaS, if I'm not mistaken, and most corporations
do not reference hosts by their fully qualifiable name in the web browser.
deis does not seem to want to show the page if I do not use the fully
qualifiable name or did I misconfigure something else.

Are you saying it's just a policy that deis does not want to render a page
unless the host header contains the fully qualifiable name?

On Wed, Jan 28, 2015 at 12:55 PM, Matthew Fisher notifications@github.com
wrote:

> That indicates an issue with the regular expression here:
> https://github.com/deis/deis/blob/master/controller/api/serializers.py#L242-L248
> 
> The error is misleading, but the domain needs to be fully qualifiable,
> i.e. django.paas-sandbox.com.
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/deis/deis/issues/2979#issuecomment-71882105.

## 

:: Sincerely,
:: Joseph Tole

Just an update, if I set wildcard records for *.paas.corp.example.com and
set my app to use the domain django.paas then I can reference it via
http://django.paas/ since our corp computers will search corp.example.com
for non qualifying domains but paas-1 seems to break the regex it uses for
allowed domains.

On Wed, Jan 28, 2015 at 1:00 PM, Joseph Tole joseph.tole@gmail.com wrote:

> the domain needs to be fully qualifiable
> 
> Why?
> 
> My point is that it should not require the domain to be fully qualifiable.
>  deis is aimed at private PaaS, if I'm not mistaken, and most corporations
> do not reference hosts by their fully qualifiable name in the web browser.
> deis does not seem to want to show the page if I do not use the fully
> qualifiable name or did I misconfigure something else.
> 
> Are you saying it's just a policy that deis does not want to render a page
> unless the host header contains the fully qualifiable name?
> 
> On Wed, Jan 28, 2015 at 12:55 PM, Matthew Fisher <notifications@github.com
> 
> > wrote:
> > 
> > That indicates an issue with the regular expression here:
> > https://github.com/deis/deis/blob/master/controller/api/serializers.py#L242-L248
> > 
> > The error is misleading, but the domain needs to be fully qualifiable,
> > i.e. django.paas-sandbox.com.
> > 
> > —
> > Reply to this email directly or view it on GitHub
> > https://github.com/deis/deis/issues/2979#issuecomment-71882105.
> 
> ## 
> 
> :: Sincerely,
> :: Joseph Tole

## 

:: Sincerely,
:: Joseph Tole

looks like the regex is wrong. As per [RFC1034](http://tools.ietf.org/html/rfc1034):

> The labels must follow the rules for ARPANET host names.  They must
> start with a letter, end with a letter or digit, and have as interior
> characters only letters, digits, and hyphen.  There are also some
> restrictions on the length.  Labels must be 63 characters or less.

So in other words, that should be a valid domain name. My apologies.

Ok. Just want to make sure I understood you right. So this _IS_ a bug and
to be resolved. Is that correct?

On Wed, Jan 28, 2015 at 1:11 PM, Matthew Fisher notifications@github.com
wrote:

> looks like the regex is wrong. As per RFC1034
> http://tools.ietf.org/html/rfc1034:
> 
> The labels must follow the rules for ARPANET host names. They must
> start with a letter, end with a letter or digit, and have as interior
> characters only letters, digits, and hyphen. There are also some
> restrictions on the length. Labels must be 63 characters or less.
> 
> So in other words, that should be a valid domain name.
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/deis/deis/issues/2979#issuecomment-71884867.

## 

:: Sincerely,
:: Joseph Tole

yes.

Thank you.

On Wed, Jan 28, 2015 at 1:21 PM, Matthew Fisher notifications@github.com
wrote:

> yes.
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/deis/deis/issues/2979#issuecomment-71886749.

## 

:: Sincerely,
:: Joseph Tole


Resolving patch:
diff --git a/controller/api/serializers.py b/controller/api/serializers.py
--- a/controller/api/serializers.py
+++ b/controller/api/serializers.py
@@ -239,23 +239,22 @@ def validate_domain(self, value):
         """
         Check that the hostname is valid
         """
-        match = re.match(
-            r'^(\*\.)?(' + settings.APP_URL_REGEX + r'\.)*([a-z0-9-]+)\.([a-z0-9]{2,})$',
-            value)
-        if not match:
+        if len(value) > 255:
+            raise serializers.ValidationError('Hostname must be 255 characters or less.')
+        if value[-1:] == ".":
+            value = value[:-1]  # strip exactly one dot from the right, if present
+        labels = value.split('.')
+        if labels[0] == '*':
             raise serializers.ValidationError(
-                "Hostname does not look like a valid hostname. "
-                "Only lowercase characters are allowed.")
-
+                'Adding a wildcard subdomain is currently not supported.')
+        allowed = re.compile("^(?!-)[a-z0-9-]{1,63}(?<!-)$", re.IGNORECASE)
+        for label in labels:
+            match = allowed.match(label)
+            if not match or '--' in label or label[-1].isdigit() or label.isdigit():
+                raise serializers.ValidationError('Hostname does not look valid.')
         if models.Domain.objects.filter(domain=value).exists():
             raise serializers.ValidationError(
                 "The domain {} is already in use by another app".format(value))
-
-        domain_parts = value.split('.')
-        if domain_parts[0] == '*':
-            raise serializers.ValidationError(
-                "Adding a wildcard subdomain is currently not supported".format(value))
-
         return value
 
 


Test patch:
diff --git a/controller/api/tests/test_domain.py b/controller/api/tests/test_domain.py
--- a/controller/api/tests/test_domain.py
+++ b/controller/api/tests/test_domain.py
@@ -48,24 +48,34 @@ def test_response_data(self):
 
     def test_manage_domain(self):
         url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
-        body = {'domain': 'test-domain.example.com'}
-        response = self.client.post(url, json.dumps(body), content_type='application/json',
-                                    HTTP_AUTHORIZATION='token {}'.format(self.token))
-        self.assertEqual(response.status_code, 201)
-        url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
-        response = self.client.get(url, content_type='application/json',
-                                   HTTP_AUTHORIZATION='token {}'.format(self.token))
-        result = response.data['results'][0]
-        self.assertEqual('test-domain.example.com', result['domain'])
-        url = '/v1/apps/{app_id}/domains/{hostname}'.format(hostname='test-domain.example.com',
-                                                            app_id=self.app_id)
-        response = self.client.delete(url, content_type='application/json',
-                                      HTTP_AUTHORIZATION='token {}'.format(self.token))
-        self.assertEqual(response.status_code, 204)
-        url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
-        response = self.client.get(url, content_type='application/json',
-                                   HTTP_AUTHORIZATION='token {}'.format(self.token))
-        self.assertEqual(0, response.data['count'])
+        test_domains = [
+            'test-domain.example.com',
+            'django.paas-sandbox',
+            'domain',
+            'not.too.loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong',
+            '3com.com',
+            'MYDOMAIN.NET',
+        ]
+        for domain in test_domains:
+            body = {'domain': domain}
+            msg = "failed on \"{}\"".format(domain)
+            response = self.client.post(url, json.dumps(body), content_type='application/json',
+                                        HTTP_AUTHORIZATION='token {}'.format(self.token))
+            self.assertEqual(response.status_code, 201, msg)
+            url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
+            response = self.client.get(url, content_type='application/json',
+                                       HTTP_AUTHORIZATION='token {}'.format(self.token))
+            result = response.data['results'][0]
+            self.assertEqual(domain, result['domain'], msg)
+            url = '/v1/apps/{app_id}/domains/{hostname}'.format(hostname=domain,
+                                                                app_id=self.app_id)
+            response = self.client.delete(url, content_type='application/json',
+                                          HTTP_AUTHORIZATION='token {}'.format(self.token))
+            self.assertEqual(response.status_code, 204, msg)
+            url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
+            response = self.client.get(url, content_type='application/json',
+                                       HTTP_AUTHORIZATION='token {}'.format(self.token))
+            self.assertEqual(0, response.data['count'], msg)
 
     def test_manage_domain_invalid_app(self):
         url = '/v1/apps/{app_id}/domains'.format(app_id="this-app-does-not-exist")
@@ -80,20 +90,20 @@ def test_manage_domain_invalid_app(self):
 
     def test_manage_domain_invalid_domain(self):
         url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
-        body = {'domain': 'this_is_an.invalid.domain'}
-        response = self.client.post(url, json.dumps(body), content_type='application/json',
-                                    HTTP_AUTHORIZATION='token {}'.format(self.token))
-        self.assertEqual(response.status_code, 400)
-        url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
-        body = {'domain': 'this-is-an.invalid.a'}
-        response = self.client.post(url, json.dumps(body), content_type='application/json',
-                                    HTTP_AUTHORIZATION='token {}'.format(self.token))
-        self.assertEqual(response.status_code, 400)
-        url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
-        body = {'domain': 'domain'}
-        response = self.client.post(url, json.dumps(body), content_type='application/json',
-                                    HTTP_AUTHORIZATION='token {}'.format(self.token))
-        self.assertEqual(response.status_code, 400)
+        test_domains = [
+            'this_is_an.invalid.domain',
+            'this-is-an.invalid.1',
+            'django.pass--sandbox',
+            'domain1',
+            '3333.com',
+            'too.looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong',
+        ]
+        for domain in test_domains:
+            msg = "failed on \"{}\"".format(domain)
+            body = {'domain': domain}
+            response = self.client.post(url, json.dumps(body), content_type='application/json',
+                                        HTTP_AUTHORIZATION='token {}'.format(self.token))
+            self.assertEqual(response.status_code, 400, msg)
 
     def test_manage_domain_wildcard(self):
         """Wildcards are not allowed for now."""