Merge lp:~jtv/maas/bug-1300059 into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 2225
Proposed branch: lp:~jtv/maas/bug-1300059
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 253 lines (+161/-8)
3 files modified
src/maasserver/models/node.py (+42/-2)
src/maasserver/models/tests/test_node.py (+117/-2)
src/maasserver/tests/test_api_node.py (+2/-4)
To merge this branch: bzr merge lp:~jtv/maas/bug-1300059
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Review via email: mp+214032@code.launchpad.net

Commit message

Validate hostnames.

Description of the change

Until now, hostnames entered for nodes were left completely unvalidated. They could contain anything, valid or not. This branch provides a validator.

The validator does not support unicode, because MAAS doesn't. If you want internationalized domain names, you'll need to encode them yourself and enter the resulting Punycode into MAAS. We could at some point support unicode input, but I believe the changes would probably have to be more extensive than just a change in validator.

Jeroen

To post a comment you must log in.
Revision history for this message
Raphaël Badin (rvb) wrote :

Looks good. As discussed on IRC, it means people with existing nodes with invalid labels will need to rename their nodes before they can do any modifications to them but:
a) the problem will be quite easy to identify (the validation error will be pretty clear)
b) I don't expect many people will have to face this problem

[0]

> If the hostname is not valid according to RFCs 952 and 1123.

I know a hostname is a hostname (as in "A Big Mac is a Big Mac") but I think this is worth repeating in a place that makes it to the documentation, i.e. in ":ivar hostname: This `Node`'s hostname." in src/maasserver/models/node.py:Node

[1]

41 + if len(label) > 63:
42 + raise ValidationError("Label is too long: '%s'." % label)

The error would be more informative if it mentioned the limit.

[2]

40 + raise ValidationError("Hostname contains empty label.")
41 + if len(label) > 63:
42 + raise ValidationError("Label is too long: '%s'." % label)

Do you think it's obvious to people what a "label", in this context, means? I must admit it was not obvious to me before I read your comments. Maybe "part"?

[3]

44 + raise ValidationError(
45 + "Label cannot start or end with hyphen: '%s'." % label)

You can use %r here, this saves you from having to include the single quotes around the string.

[4]

59 + hostname = CharField(
60 + max_length=255, default='', blank=True, unique=True,
61 + validators=[validate_hostname])

Once more, maybe mention the RFCs in the help_text.

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

> [0]
>
> > If the hostname is not valid according to RFCs 952 and 1123.
>
> I know a hostname is a hostname (as in "A Big Mac is a Big Mac") but I think
> this is worth repeating in a place that makes it to the documentation, i.e. in
> ":ivar hostname: This `Node`'s hostname." in
> src/maasserver/models/node.py:Node

OK, I put it in the ivar documentation.

> [1]
>
> 41 + if len(label) > 63:
> 42 + raise ValidationError("Label is too long: '%s'." % label)
>
> The error would be more informative if it mentioned the limit.

Made the message look much like the one for the overall length.

> [2]
>
> 40 + raise ValidationError("Hostname contains empty label.")
> 41 + if len(label) > 63:
> 42 + raise ValidationError("Label is too long: '%s'." % label)
>
> Do you think it's obvious to people what a "label", in this context, means? I
> must admit it was not obvious to me before I read your comments. Maybe "part"?

It's not obvious, no. I changed it to say "name."

> [3]
>
> 44 + raise ValidationError(
> 45 + "Label cannot start or end with hyphen: '%s'." %
> label)
>
> You can use %r here, this saves you from having to include the single quotes
> around the string.

I never liked that, because it introduces magic characters that are neither in the format string nor in the argument. But if you prefer that, I'll change it.

> [4]
>
> 59 + hostname = CharField(
> 60 + max_length=255, default='', blank=True, unique=True,
> 61 + validators=[validate_hostname])
>
> Once more, maybe mention the RFCs in the help_text.

There is no help_text here, or anywhere in our models that I know of. I think that's because we normally have the help text in the form. Wouldn't that one override any help_text from the model? Anyway, the text we have in the form is already quite long and goes into the relationship between the hostname and the FQDN, which I think is much more important and potentially surprising. So I left this as it was.

Thanks for the review.

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (20.5 KiB)

The attempt to merge lp:~jtv/maas/bug-1300059 into lp:maas failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Hit http://security.ubuntu.com trusty-security Release.gpg
Hit http://security.ubuntu.com trusty-security Release
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Get:1 http://nova.clouds.archive.ubuntu.com trusty Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg
Get:2 http://nova.clouds.archive.ubuntu.com trusty Release [58.5 kB]
Hit http://security.ubuntu.com trusty-security/main Sources
Hit http://nova.clouds.archive.ubuntu.com trusty-updates Release
Hit http://security.ubuntu.com trusty-security/universe Sources
Hit http://security.ubuntu.com trusty-security/main amd64 Packages
Hit http://security.ubuntu.com trusty-security/universe amd64 Packages
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Get:3 http://nova.clouds.archive.ubuntu.com trusty/main Sources [1,076 kB]
Ign http://security.ubuntu.com trusty-security/main Translation-en_US
Ign http://security.ubuntu.com trusty-security/universe Translation-en_US
Get:4 http://nova.clouds.archive.ubuntu.com trusty/universe Sources [6,405 kB]
Get:5 http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages [1,370 kB]
Get:6 http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages [5,869 kB]
Get:7 http://nova.clouds.archive.ubuntu.com trusty/main Translation-en [772 kB]
Get:8 http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en [4,046 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en_US
Fetched 19.6 MB in 7s (2,667 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 bind9 bind9utils build-essential bzr-builddeb curl daemontools debhelper dh-apport distro-info dnsutils firefox freeipmi-tools ipython isc-dhcp-common libjs-raphael libjs-yui3-full libjs-yui3-min libpq-dev make postgresql python-amqplib python-bzrlib python-celery python-convoy python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-formencode python-httplib2 python-jinja2 python-jsonschema python-lockfile python-lxml python-netaddr ...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/models/node.py'
2--- src/maasserver/models/node.py 2014-04-02 16:40:39 +0000
3+++ src/maasserver/models/node.py 2014-04-03 16:12:48 +0000
4@@ -23,6 +23,7 @@
5 repeat,
6 )
7 import random
8+import re
9 from string import whitespace
10 from uuid import uuid1
11
12@@ -152,6 +153,43 @@
13 """Raised when a node has an unknown power type."""
14
15
16+def validate_hostname(hostname):
17+ """Validator for hostnames.
18+
19+ :param hostname: Input value for a host name. May include domain.
20+ :raise ValidationError: If the hostname is not valid according to RFCs 952
21+ and 1123.
22+ """
23+ # Valid characters within a hostname label: ASCII letters, ASCII digits,
24+ # hyphens, and underscores. Not all are always valid.
25+ # Technically we could write all of this as a single regex, but it's not
26+ # very good for code maintenance.
27+ label_chars = re.compile('[a-zA-Z0-9_-]*$')
28+
29+ if len(hostname) > 255:
30+ raise ValidationError(
31+ "Hostname is too long. Maximum allowed is 255 characters.")
32+ # A hostname consists of "labels" separated by dots.
33+ labels = hostname.split('.')
34+ if '_' in labels[0]:
35+ # The host label cannot contain underscores; the rest of the name can.
36+ raise ValidationError(
37+ "Host label cannot contain underscore: %r." % labels[0])
38+ for label in labels:
39+ if len(label) == 0:
40+ raise ValidationError("Hostname contains empty name.")
41+ if len(label) > 63:
42+ raise ValidationError(
43+ "Name is too long: %r. Maximum allowed is 63 characters."
44+ % label)
45+ if label.startswith('-') or label.endswith('-'):
46+ raise ValidationError(
47+ "Name cannot start or end with hyphen: %r." % label)
48+ if not label_chars.match(label):
49+ raise ValidationError(
50+ "Name contains disallowed characters: %r." % label)
51+
52+
53 class NodeManager(Manager):
54 """A utility to manage the collection of Nodes."""
55
56@@ -408,7 +446,7 @@
57
58 :ivar system_id: The unique identifier for this `Node`.
59 (e.g. 'node-41eba45e-4cfa-11e1-a052-00225f89f211').
60- :ivar hostname: This `Node`'s hostname.
61+ :ivar hostname: This `Node`'s hostname. Must conform to RFCs 952 and 1123.
62 :ivar status: This `Node`'s status. See the vocabulary
63 :class:`NODE_STATUS`.
64 :ivar owner: This `Node`'s owner if it's in use, None otherwise.
65@@ -428,7 +466,9 @@
66 max_length=41, unique=True, default=generate_node_system_id,
67 editable=False)
68
69- hostname = CharField(max_length=255, default='', blank=True, unique=True)
70+ hostname = CharField(
71+ max_length=255, default='', blank=True, unique=True,
72+ validators=[validate_hostname])
73
74 status = IntegerField(
75 max_length=10, choices=NODE_STATUS_CHOICES, editable=False,
76
77=== modified file 'src/maasserver/models/tests/test_node.py'
78--- src/maasserver/models/tests/test_node.py 2014-03-27 04:15:45 +0000
79+++ src/maasserver/models/tests/test_node.py 2014-04-03 16:12:48 +0000
80@@ -40,6 +40,7 @@
81 from maasserver.models.node import (
82 generate_hostname,
83 NODE_TRANSITIONS,
84+ validate_hostname,
85 )
86 from maasserver.models.user import create_auth_token
87 from maasserver.testing import reload_object
88@@ -83,6 +84,114 @@
89 self.assertEqual(sizes, [len(hostname) for hostname in hostnames])
90
91
92+class TestHostnameValidator(MAASTestCase):
93+ """Tests for the validation of hostnames.
94+
95+ Specifications based on:
96+ http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
97+
98+ This does not support Internationalized Domain Names. To do so, we'd have
99+ to accept and store unicode, but use the Punycode-encoded version. The
100+ validator would have to validate both versions: the unicode input for
101+ invalid characters, and the encoded version for length.
102+ """
103+ def make_maximum_hostname(self):
104+ """Create a hostname of the maximum permitted length.
105+
106+ The maximum permitted length is 255 characters. The last label in the
107+ hostname will not be of the maximum length, so tests can still append a
108+ character to it without creating an invalid label.
109+
110+ The hostname is not randomised, so do not count on it being unique.
111+ """
112+ # A hostname may contain any number of labels, separated by dots.
113+ # Each of the labels has a maximum length of 63 characters, so this has
114+ # to be built up from multiple labels.
115+ ten_chars = ('a' * 9) + '.'
116+ hostname = ten_chars * 25 + ('b' * 5)
117+ self.assertEqual(255, len(hostname))
118+ return hostname
119+
120+ def assertAccepts(self, hostname):
121+ """Assertion: the validator accepts `hostname`."""
122+ try:
123+ validate_hostname(hostname)
124+ except ValidationError as e:
125+ raise AssertionError(unicode(e))
126+
127+ def assertRejects(self, hostname):
128+ """Assertion: the validator rejects `hostname`."""
129+ self.assertRaises(ValidationError, validate_hostname, hostname)
130+
131+ def test_accepts_ascii_letters(self):
132+ self.assertAccepts('abcde')
133+
134+ def test_accepts_dots(self):
135+ self.assertAccepts('abc.def')
136+
137+ def test_rejects_adjacent_dots(self):
138+ self.assertRejects('abc..def')
139+
140+ def test_rejects_leading_dot(self):
141+ self.assertRejects('.abc')
142+
143+ def test_rejects_trailing_dot(self):
144+ self.assertRejects('abc.')
145+
146+ def test_accepts_ascii_digits(self):
147+ self.assertAccepts('abc123')
148+
149+ def test_accepts_leading_digits(self):
150+ # Leading digits used to be forbidden, but are now allowed.
151+ self.assertAccepts('123abc')
152+
153+ def test_rejects_whitespace(self):
154+ self.assertRejects('a b')
155+ self.assertRejects('a\nb')
156+ self.assertRejects('a\tb')
157+
158+ def test_rejects_other_ascii_characters(self):
159+ self.assertRejects('a?b')
160+ self.assertRejects('a!b')
161+ self.assertRejects('a,b')
162+ self.assertRejects('a:b')
163+ self.assertRejects('a;b')
164+ self.assertRejects('a+b')
165+ self.assertRejects('a=b')
166+
167+ def test_accepts_underscore_in_domain(self):
168+ self.assertAccepts('host.local_domain')
169+
170+ def test_rejects_underscore_in_host(self):
171+ self.assertRejects('host_name.local')
172+
173+ def test_accepts_hyphen(self):
174+ self.assertAccepts('a-b')
175+
176+ def test_rejects_hyphen_at_start_of_label(self):
177+ self.assertRejects('-ab')
178+
179+ def test_rejects_hyphen_at_end_of_label(self):
180+ self.assertRejects('ab-')
181+
182+ def test_accepts_maximum_valid_length(self):
183+ self.assertAccepts(self.make_maximum_hostname())
184+
185+ def test_rejects_oversized_hostname(self):
186+ self.assertRejects(self.make_maximum_hostname() + 'x')
187+
188+ def test_accepts_maximum_label_length(self):
189+ self.assertAccepts('a' * 63)
190+
191+ def test_rejects_oversized_label(self):
192+ self.assertRejects('b' * 64)
193+
194+ def test_rejects_nonascii_letter(self):
195+ # The \u03be is the Greek letter xi. Perfectly good letter, just not
196+ # ASCII.
197+ self.assertRejects('\u03be')
198+
199+
200 class NodeTest(MAASServerTestCase):
201
202 def test_system_id(self):
203@@ -94,6 +203,12 @@
204 self.assertEqual(len(node.system_id), 41)
205 self.assertTrue(node.system_id.startswith('node-'))
206
207+ def test_hostname_is_validated(self):
208+ bad_hostname = '-_?!@*-'
209+ self.assertRaises(
210+ ValidationError,
211+ factory.make_node, hostname=bad_hostname)
212+
213 def test_work_queue_returns_nodegroup_uuid(self):
214 nodegroup = factory.make_node_group()
215 node = factory.make_node(nodegroup=nodegroup)
216@@ -217,14 +332,14 @@
217 Config.objects.set_config("enlistment_domain", '')
218 existing_node = factory.make_node(hostname='hostname')
219
220- hostnames = [existing_node.hostname, "new_hostname"]
221+ hostnames = [existing_node.hostname, "new-hostname"]
222 self.patch(
223 node_module, "generate_hostname",
224 lambda size: hostnames.pop(0))
225
226 node = factory.make_node()
227 node.set_random_hostname()
228- self.assertEqual('new_hostname', node.hostname)
229+ self.assertEqual('new-hostname', node.hostname)
230
231 def test_get_effective_power_type_raises_if_not_set(self):
232 node = factory.make_node(power_type='')
233
234=== modified file 'src/maasserver/tests/test_api_node.py'
235--- src/maasserver/tests/test_api_node.py 2014-03-06 01:29:56 +0000
236+++ src/maasserver/tests/test_api_node.py 2014-04-03 16:12:48 +0000
237@@ -503,14 +503,12 @@
238 hostname='diane', owner=self.logged_in_user,
239 architecture=make_usable_architecture(self))
240 response = self.client_put(
241- self.get_node_uri(node), {'hostname': 'too long' * 100})
242+ self.get_node_uri(node), {'hostname': '.'})
243 parsed_result = json.loads(response.content)
244
245 self.assertEqual(httplib.BAD_REQUEST, response.status_code)
246 self.assertEqual(
247- {'hostname':
248- ['Ensure this value has at most 255 characters '
249- '(it has 800).']},
250+ {'hostname': ["Hostname contains empty name."]},
251 parsed_result)
252
253 def test_PUT_refuses_to_update_invisible_node(self):