Merge lp:~mpontillo/maas/bug-1433622-disallow-absolute-fqdn-cluster-names into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 3770
Proposed branch: lp:~mpontillo/maas/bug-1433622-disallow-absolute-fqdn-cluster-names
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 251 lines (+113/-17)
5 files modified
src/maasserver/api/tests/test_node.py (+1/-1)
src/maasserver/models/nodegroup.py (+27/-1)
src/maasserver/models/tests/test_nodegroup.py (+30/-0)
src/maasserver/utils/dns.py (+35/-15)
src/maasserver/utils/tests/test_dns.py (+20/-0)
To merge this branch: bzr merge lp:~mpontillo/maas/bug-1433622-disallow-absolute-fqdn-cluster-names
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Andres Rodriguez (community) Needs Information
Review via email: mp+254826@code.launchpad.net

Commit message

Add a validator for domain names, and use it to validate the NodeGroup (cluster) name. In addition, add a CharField subclass specific to DNS names, which will strip any trailing '.' before saving to the database, and make use of the validator by default.

Description of the change

Refactored the hostname validator to separate hostname validation functionality from domain name validation functionality.

Added DomainNameField, a CharField subclass whose sole job is to normalize DNS names by removing all whitespace and trailing '.' characters, and use the new validator.

To post a comment you must log in.
Revision history for this message
Andres Rodriguez (andreserl) wrote :

As discussed over IRC, using a final '.' in a zone is allowed per standard and MAAS would need to support this.

This means that if a user inputs the zone in the cluster controller page as:

'maas.com.' or 'maas.'

it should continue to be allowed and should have the same result as:

'maas.com' or 'maas'

That being said, even when a zone in the form of 'maas.com' or 'maas' is given, DNS config files are written with a final '.':

@ IN SOA maas.com. nobody.example.com. (

or

@ IN SOA maas. nobody.example.com. (

review: Needs Fixing
Revision history for this message
Andres Rodriguez (andreserl) wrote :

Changing this to needs information so we can start a discussion

review: Needs Information
Revision history for this message
Mike Pontillo (mpontillo) wrote :

There are two possible lines of thinking here:

(1) We should prevent the user from creating a database record that will cause incorrect behavior. (that's what this patch does)

(2) We should accept the user's valid-but-incorrect-behavior-causing input and transform it into something that does not cause incorrect behavior.

This patch addresses (1) but not (2). Thus, with this patch, if you type "andres.maas." into the name for a NodeGroup, you will get an error.

A better fix would automatically transform "andres.maas." into "andres.maas" *before* storing it in the database. (The reason you want to do it *before* is for database normalization; to MAAS, all DNS suffixes are absolute, fully-qualified names - so the trailing "." should have no meaning. Therefore, if someone enters "andres.maas." when there is already an "andres.maas", that should be a unique constraint violation.)

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Suggestion: let's merge this simpler workaround first, to prevent users from shooting themselves in the foot until the complete fix is devised. Thoughts?

Revision history for this message
Mike Pontillo (mpontillo) wrote :

I figured out an elegant way to automatically strip off any trailing '.' characters in the model before the object is saved. I think this will avoid the issue. But I left the validators in place, just in case. (besides, there are likely to be other ways users can screw up their DNS names.)

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Need more information on this implementation. I overall need more information on how MAAS validates hostnames, as this also affects my other branch that you reviewed. We should get this nailed down ASAP tomorrow.

review: Needs Information
Revision history for this message
Mike Pontillo (mpontillo) wrote :

Raphael and I discussed this. Our hostname validation is severely lacking at the moment. The validation code in dns.py you were looking at was recently moved there by myself, when I was refactoring some other validations. We should use it in more places, most likely.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/tests/test_node.py'
2--- src/maasserver/api/tests/test_node.py 2015-03-25 15:33:23 +0000
3+++ src/maasserver/api/tests/test_node.py 2015-04-03 16:20:24 +0000
4@@ -691,7 +691,7 @@
5
6 self.assertEqual(httplib.BAD_REQUEST, response.status_code)
7 self.assertEqual(
8- {'hostname': ["Hostname contains empty name."]},
9+ {'hostname': ["DNS name contains an empty label."]},
10 parsed_result)
11
12 def test_PUT_refuses_to_update_invisible_node(self):
13
14=== modified file 'src/maasserver/models/nodegroup.py'
15--- src/maasserver/models/nodegroup.py 2015-03-26 16:33:06 +0000
16+++ src/maasserver/models/nodegroup.py 2015-04-03 16:20:24 +0000
17@@ -43,6 +43,7 @@
18 from maasserver.models.timestampedmodel import TimestampedModel
19 from maasserver.models.user import get_creds_tuple
20 from maasserver.rpc import getClientFor
21+from maasserver.utils.dns import validate_domain_name
22 from piston.models import (
23 KEY_SIZE,
24 Token,
25@@ -142,6 +143,31 @@
26 return self.filter(status=NODEGROUP_STATUS.ACCEPTED)
27
28
29+class DomainNameField(CharField):
30+ """Custom Django field that strips whitespace and trailing '.' characters
31+ from DNS domain names before validating and saving to the database. Also,
32+ validates that the domain name is valid according to RFCs 952 and 1123.
33+ (Note that this field type should NOT be used for hostnames, since the set
34+ of valid hostnames is smaller than the set of valid domain names.)
35+ """
36+ def __init__(self, *args, **kwargs):
37+ validators = kwargs.pop('validators', [])
38+ validators.append(validate_domain_name)
39+ kwargs['validators'] = validators
40+ super(DomainNameField, self).__init__(*args, **kwargs)
41+
42+ # Here we are using (abusing?) the to_pytion() function to coerce and
43+ # normalize this type. Django does not have a function intended purely
44+ # to normalize before saving to the database, so to_python() is the next
45+ # closest alternative. For more information, see:
46+ # https://docs.djangoproject.com/en/1.6/ref/forms/validation/
47+ # https://code.djangoproject.com/ticket/6362
48+ def to_python(self, value):
49+ value = super(DomainNameField, self).to_python(value)
50+ value = value.strip().rstrip('.')
51+ return value
52+
53+
54 NODEGROUP_CLUSTER_NAME_TEMPLATE = "Cluster %(uuid)s"
55
56
57@@ -156,7 +182,7 @@
58 max_length=100, unique=True, editable=True, blank=True, null=False)
59
60 # A node group's name is also used for the group's DNS zone.
61- name = CharField(
62+ name = DomainNameField(
63 max_length=80, unique=False, editable=True, blank=True, null=False)
64
65 status = IntegerField(
66
67=== modified file 'src/maasserver/models/tests/test_nodegroup.py'
68--- src/maasserver/models/tests/test_nodegroup.py 2015-03-26 16:33:06 +0000
69+++ src/maasserver/models/tests/test_nodegroup.py 2015-04-03 16:20:24 +0000
70@@ -18,6 +18,7 @@
71 from urlparse import urlparse
72
73 from apiclient.creds import convert_string_to_tuple
74+from django.core.exceptions import ValidationError
75 from django.db.models.signals import post_save
76 import django.dispatch
77 from maasserver.bootresources import get_simplestream_endpoint
78@@ -64,6 +65,7 @@
79 )
80 from provisioningserver.rpc.exceptions import NoConnectionsAvailable
81 from provisioningserver.utils.enum import map_enum
82+from testtools import ExpectedException
83 from testtools.matchers import (
84 EndsWith,
85 Equals,
86@@ -251,6 +253,34 @@
87
88 class TestNodeGroup(MAASServerTestCase):
89
90+ def test_nodegroup_created_with_factory_validates(self):
91+ try:
92+ ng = factory.make_NodeGroup(name="maas")
93+ ng.clean_fields()
94+ except ValidationError:
95+ self.fail("Factory should create a NodeGroup that validates.")
96+
97+ def test_create_nodegroup_with_absolute_dns_name_succeeds(self):
98+ ng = factory.make_NodeGroup(name="maas.")
99+ self.assertThat(ng.name, Equals("maas."))
100+ ng.clean_fields()
101+ self.assertThat(ng.name, Equals("maas"))
102+ ng.save()
103+ self.assertThat(ng.name, Equals("maas"))
104+
105+ def test_create_nodegroup_with_multiple_dots_dns_name_succeeds(self):
106+ ng = factory.make_NodeGroup(name="maas.ubuntu.com.")
107+ self.assertThat(ng.name, Equals("maas.ubuntu.com."))
108+ ng.clean_fields()
109+ self.assertThat(ng.name, Equals("maas.ubuntu.com"))
110+ ng.save()
111+ self.assertThat(ng.name, Equals("maas.ubuntu.com"))
112+
113+ def test_create_nodegroup_with_invalid_dns_name_fails(self):
114+ with ExpectedException(ValidationError):
115+ ng = factory.make_NodeGroup(name="m@@$")
116+ ng.clean_fields()
117+
118 def test_delete_cluster_with_nodes(self):
119 nodegroup = factory.make_NodeGroup()
120 factory.make_Node(nodegroup=nodegroup)
121
122=== modified file 'src/maasserver/utils/dns.py'
123--- src/maasserver/utils/dns.py 2015-03-26 00:12:12 +0000
124+++ src/maasserver/utils/dns.py 2015-04-03 16:20:24 +0000
125@@ -25,12 +25,12 @@
126 ]
127
128
129-def validate_hostname(hostname):
130- """Validator for hostnames.
131+def validate_domain_name(name):
132+ """Validator for domain names.
133
134- :param hostname: Input value for a host name. May include domain.
135- :raise ValidationError: If the hostname is not valid according to RFCs 952
136- and 1123.
137+ :param name: Input value for a domain name. Must not include hostname.
138+ :raise ValidationError: If the domain name is not valid according to
139+ RFCs 952 and 1123.
140 """
141 # Valid characters within a hostname label: ASCII letters, ASCII digits,
142 # hyphens, and underscores. Not all are always valid.
143@@ -38,28 +38,48 @@
144 # very good for code maintenance.
145 label_chars = re.compile('[a-zA-Z0-9_-]*$')
146
147- if len(hostname) > 255:
148+ if len(name) > 255:
149 raise ValidationError(
150 "Hostname is too long. Maximum allowed is 255 characters.")
151 # A hostname consists of "labels" separated by dots.
152- labels = hostname.split('.')
153- if '_' in labels[0]:
154- # The host label cannot contain underscores; the rest of the name can.
155- raise ValidationError(
156- "Host label cannot contain underscore: %r." % labels[0])
157+ labels = name.split('.')
158 for label in labels:
159 if len(label) == 0:
160- raise ValidationError("Hostname contains empty name.")
161+ raise ValidationError("DNS name contains an empty label.")
162 if len(label) > 63:
163 raise ValidationError(
164- "Name is too long: %r. Maximum allowed is 63 characters."
165+ "Label is too long: %r. Maximum allowed is 63 characters."
166 % label)
167 if label.startswith('-') or label.endswith('-'):
168 raise ValidationError(
169- "Name cannot start or end with hyphen: %r." % label)
170+ "Label cannot start or end with hyphen: %r." % label)
171 if not label_chars.match(label):
172 raise ValidationError(
173- "Name contains disallowed characters: %r." % label)
174+ "Label contains disallowed characters: %r." % label)
175+
176+
177+def validate_hostname(hostname):
178+ """Validator for hostnames.
179+
180+ :param hostname: Input value for a hostname. May include domain.
181+ :raise ValidationError: If the hostname is not valid according to RFCs 952
182+ and 1123.
183+ """
184+ # Valid characters within a hostname label: ASCII letters, ASCII digits,
185+ # hyphens, and underscores. Not all are always valid.
186+ # Technically we could write all of this as a single regex, but it's not
187+ # very good for code maintenance.
188+
189+ if len(hostname) > 255:
190+ raise ValidationError(
191+ "Hostname is too long. Maximum allowed is 255 characters.")
192+ # A hostname consists of "labels" separated by dots.
193+ host_part = hostname.split('.')[0]
194+ if '_' in host_part:
195+ # The host label cannot contain underscores; the rest of the name can.
196+ raise ValidationError(
197+ "Host label cannot contain underscore: %r." % host_part)
198+ validate_domain_name(hostname)
199
200
201 def get_ip_based_hostname(ip):
202
203=== modified file 'src/maasserver/utils/tests/test_dns.py'
204--- src/maasserver/utils/tests/test_dns.py 2015-03-26 00:12:12 +0000
205+++ src/maasserver/utils/tests/test_dns.py 2015-04-03 16:20:24 +0000
206@@ -13,6 +13,7 @@
207 from django.core.exceptions import ValidationError
208 from maasserver.utils.dns import (
209 get_ip_based_hostname,
210+ validate_domain_name,
211 validate_hostname,
212 )
213 from testtools.matchers import (
214@@ -70,12 +71,26 @@
215 """Assertion: the validator rejects `hostname`."""
216 self.assertRaises(ValidationError, validate_hostname, hostname)
217
218+ def assertDomainValidatorAccepts(self, domain_name):
219+ """Assertion: the validator rejects `domain_name`."""
220+ try:
221+ validate_domain_name(domain_name)
222+ except ValidationError as e:
223+ raise AssertionError(unicode(e))
224+
225+ def assertDomainValidatorRejects(self, hostname):
226+ """Assertion: the validator rejects `hostname`."""
227+ self.assertRaises(ValidationError, validate_domain_name, hostname)
228+
229 def test_accepts_ascii_letters(self):
230 self.assertAccepts('abcde')
231
232 def test_accepts_dots(self):
233 self.assertAccepts('abc.def')
234
235+ def test_accepts_subdomain(self):
236+ self.assertAccepts('abc.def.ubuntu.com')
237+
238 def test_rejects_adjacent_dots(self):
239 self.assertRejects('abc..def')
240
241@@ -138,6 +153,11 @@
242 # ASCII.
243 self.assertRejects('\u03be')
244
245+ def test_accepts_domain_underscores(self):
246+ self.assertDomainValidatorAccepts('_foo')
247+ self.assertDomainValidatorAccepts('_foo._bar')
248+ self.assertDomainValidatorAccepts('_.o_O._')
249+
250
251 class TestIpBasedHostnameGenerator(MAASTestCase):
252