Merge lp:~rvb/maas/pxe-off-preseed into lp:maas/trunk

Proposed by Raphaël Badin on 2012-06-20
Status: Merged
Approved by: Raphaël Badin on 2012-06-20
Approved revision: 664
Merged at revision: 658
Proposed branch: lp:~rvb/maas/pxe-off-preseed
Merge into: lp:maas/trunk
Diff against target: 320 lines (+145/-7)
8 files modified
contrib/preseeds_v2/generic (+1/-1)
src/maasserver/preseed.py (+28/-3)
src/maasserver/tests/test_preseed.py (+12/-1)
src/maasserver/tests/test_utils.py (+33/-1)
src/maasserver/utils.py (+21/-0)
src/metadataserver/api.py (+20/-0)
src/metadataserver/tests/test_api.py (+12/-0)
src/metadataserver/urls.py (+18/-1)
To merge this branch: bzr merge lp:~rvb/maas/pxe-off-preseed
Reviewer Review Type Date Requested Status
Gavin Panella (community) 2012-06-20 Approve on 2012-06-20
Francesco Banconi (community) code* 2012-06-20 Approve on 2012-06-20
Review via email: mp+111225@code.launchpad.net

Commit Message

Add an anonymous method to turn off PXE booting. Use that method in the preseed template.

Description of the Change

This branch adds an anonymous method to turn off PXE booting (to the metadata service) and uses that method to generate the url put in the preseed file. That url will be called by the node once at the end of the installation process.

= Pre-imp =

This is part of the plan which was devised last week with Gavin (see http://bit.ly/KXW5ZU for details) and I've had a pre-imp call with Julian about this.

= Notes =

- I've filed bug 1015559 about the fact that the new method does not require authentication. The bug is referenced in the code.

- The call to "wget" is now a POST request. Cobbler uses a GET request to modify data :(.

- Drive-by fix: I've fixed the value of 'server_name' in the context used when rendering the preseed file. It now uses the method get_maas_server_host.

To post a comment you must log in.
Francesco Banconi (frankban) wrote :

The code looks good Raphaël.
Just 3 details/suggestions for the absolute_reverse function:

1. :param `view_name` can also be a view callable reference.
2. maybe we could continue supporting the other kwargs that can be passed to `reverse`, i.e. `urlconf` and `current_app`.
3. you can take into consideration `urlparse.urljoin`

review: Approve (code*)
Gavin Panella (allenap) wrote :

Francesco wrote:
> The code looks good Raphaël.
> Just 3 details/suggestions for the absolute_reverse function:
>
> 1. :param `view_name` can also be a view callable reference.

Took a (small) bit of hunting around to find what you were referring
to. Including some context would be useful when writing a review. It's
useful also when reading comments after the preview diff has been
updated with newer changes.

> 2. maybe we could continue supporting the other kwargs that can be
> passed to `reverse`, i.e. `urlconf` and `current_app`.

It looks like they are accepted... or am I missing something?

> 3. you can take into consideration `urlparse.urljoin`

+1

I have some other comments, but they're all minor.

[1]

+ wget "{{node_disable_pxe_url}}" --post-data "{{node_disable_pxe_data}}" -O /dev/null && \

These parameters should probably be escaped with pipes.quote(), a bit
like ShellTemplate does by default (though, for preseeds, not by
default).

Adding, say, an "escape" object into the template's namespace might be
an interesting approach here, then you could write:

    wget "{{node_disable_pxe_url | escape.shell}}" ...

or, for example:

    {{something_else | escape.html}}

But that's just an idea off the top of my head.

[2]

+def absolute_reverse(view_name, *args, **kwargs):

Not tempted to call it canonical_reverse()? ;)

[3]

+ response = self.client.post(
+ anon_netboot_off_url, {'op': 'netboot_off'})

The phrase "netboot off" does not contain a verb. It might be clearer
if it did, "disable netboot" or "switch netboot off" for example. Or,
for bonus points, make it RESTfulish and permit something like:

  wget ".../path/to/node?op=update" --post-data '{"netboot": false}'

[4]

+ # XXX: rvb 2012-06-20 bug=1015559: This method is accessible
+ # without authentication. This is a security threat.

I guess it's a denial of service vulnerability. It won't leak
information or permit unrelated modifications, so it's relatively
minor. I've updated the bug.

review: Approve
Francesco Banconi (frankban) wrote :

> Took a (small) bit of hunting around to find what you were referring
> to. Including some context would be useful when writing a review. It's
> useful also when reading comments after the preview diff has been
> updated with newer changes.

Good point Gavin, thank you.

> > 2. maybe we could continue supporting the other kwargs that can be
> > passed to `reverse`, i.e. `urlconf` and `current_app`.
>
> It looks like they are accepted... or am I missing something?

No you are not, my fault, they are already accepted.

lp:~rvb/maas/pxe-off-preseed updated on 2012-06-20
660. By Raphaël Badin on 2012-06-20

Use urlparse.urljoin.

Raphaël Badin (rvb) wrote :

Thanks for the review Francesco!

Like Gavin said, I think it's really worth it to add a little of context when you're suggesting something. My suggestion would be to simply include 1 or 2 lines from the diff. Like:

35 + server_host = get_maas_server_host()
36 + # Create the url and the url-data (POST parameters) used to turn off

> 1. :param `view_name` can also be a view callable reference.

Indeed, I've updated the docstring.

> 2. maybe we could continue supporting the other kwargs that can be passed to
> `reverse`, i.e. `urlconf` and `current_app`.

It's already done actually, they will be passed down to Django's reverse method as part of *args, **kwargs.

> 3. you can take into consideration `urlparse.urljoin`

Good idea, done.

lp:~rvb/maas/pxe-off-preseed updated on 2012-06-20
661. By Raphaël Badin on 2012-06-20

Fix doc.

662. By Raphaël Badin on 2012-06-20

Add escape filter.

Raphaël Badin (rvb) wrote :

Thanks for the review!

> [1]
>
> + wget "{{node_disable_pxe_url}}" --post-data "{{node_disable_pxe_data}}"
> -O /dev/null && \
>
> These parameters should probably be escaped with pipes.quote(), a bit
> like ShellTemplate does by default (though, for preseeds, not by
> default).
>
> Adding, say, an "escape" object into the template's namespace might be
> an interesting approach here, then you could write:
>
> wget "{{node_disable_pxe_url | escape.shell}}" ...
>
> or, for example:
>
> {{something_else | escape.html}}
>
> But that's just an idea off the top of my head.

That's an excellent idea, I've implemented it. It also justifies the existence of the specialized template class PreseedTemplate.

> [2]

> Not tempted to call it canonical_reverse()? ;)

Well… no :)

> [3]
>
> + response = self.client.post(
> + anon_netboot_off_url, {'op': 'netboot_off'})
>
> The phrase "netboot off" does not contain a verb. It might be clearer
> if it did, "disable netboot" or "switch netboot off" for example. Or,
> for bonus points, make it RESTfulish and permit something like:
>
> wget ".../path/to/node?op=update" --post-data '{"netboot": false}'

Currently, this is in sync with the authenticated version of the same API method. I'll check with Julian who's created the original implementation before I change that.

> [4]
>
> + # XXX: rvb 2012-06-20 bug=1015559: This method is accessible
> + # without authentication. This is a security threat.
>
> I guess it's a denial of service vulnerability. It won't leak
> information or permit unrelated modifications, so it's relatively
> minor. I've updated the bug.

Right, thanks.

lp:~rvb/maas/pxe-off-preseed updated on 2012-06-20
663. By Raphaël Badin on 2012-06-20

Fix doc.

664. By Raphaël Badin on 2012-06-20

Fix class name.

Julian Edwards (julian-edwards) wrote :

On Wednesday 20 June 2012 16:27:21 you wrote:
> > [3]
> >
> >
> >
> > + response = self.client.post(
> > + anon_netboot_off_url, {'op': 'netboot_off'})
> >
> >
> >
> > The phrase "netboot off" does not contain a verb. It might be clearer
> > if it did, "disable netboot" or "switch netboot off" for example. Or,
> >
> > for bonus points, make it RESTfulish and permit something like:
> >
> > wget ".../path/to/node?op=update" --post-data '{"netboot": false}'
>
> Currently, this is in sync with the authenticated version of the same API
> method. I'll check with Julian who's created the original implementation
> before I change that.

I thought long and hard about whether to have a single operation with multiple
parameters.

In the end I went with two, "netboot_on" and "netboot_off". I guess they are
not verbish enough, but the rationale was that it would be easier for shell
scripts to pass fewer args and it's also more consistent with Cobbler (which
may be considered bad :D)

Making it RESTful-ish (-ish making me laugh here) is an interesting one. If
we want this anonymous for now it may be better to move this entirely over to
the maasserver API? The only reason I put it on the metadata server is
because of the existing OAuth key arrangement.

Thoughts?

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'contrib/preseeds_v2/generic'
2--- contrib/preseeds_v2/generic 2012-06-19 16:17:12 +0000
3+++ contrib/preseeds_v2/generic 2012-06-20 16:31:19 +0000
4@@ -19,6 +19,6 @@
5 # Executes late command and disables PXE.
6 d-i preseed/late_command string true && \
7 in-target sh -c 'f=$1; shift; echo $0 > $f && chmod 0440 $f $*' 'ubuntu ALL=(ALL) NOPASSWD: ALL' /etc/sudoers.d/maas && \
8- wget "{{node_disable_pxe_url}}" -O /dev/null && \
9+ wget "{{node_disable_pxe_url|escape.shell}}" --post-data "{{node_disable_pxe_data|escape.shell}}" -O /dev/null && \
10 true
11 {{enddef}}
12
13=== modified file 'src/maasserver/preseed.py'
14--- src/maasserver/preseed.py 2012-06-19 16:01:07 +0000
15+++ src/maasserver/preseed.py 2012-06-20 16:31:19 +0000
16@@ -12,12 +12,16 @@
17 __metaclass__ = type
18 __all__ = []
19
20+from collections import namedtuple
21 from os.path import join
22+from pipes import quote
23+from urllib import urlencode
24 from urlparse import urlparse
25
26 from django.conf import settings
27 from maasserver.models import Config
28 from maasserver.provisioning import compose_preseed
29+from maasserver.utils import absolute_reverse
30 import tempita
31
32
33@@ -89,8 +93,23 @@
34 return None, None
35
36
37+def get_escape_singleton():
38+ """Return a singleton containing methods to escape various formats used in
39+ the preseed templates.
40+ """
41+ Escape = namedtuple('Escape', 'shell')
42+ return Escape(shell=quote)
43+
44+
45 class PreseedTemplate(tempita.Template):
46- """A Tempita template specialised for preseed rendering."""
47+ """A Tempita template specialised for preseed rendering.
48+
49+ It provides a filter named 'escape' which contains methods to escape
50+ various formats used in the template."""
51+
52+ default_namespace = dict(
53+ tempita.Template.default_namespace,
54+ escape=get_escape_singleton())
55
56
57 class TemplateNotFoundError(Exception):
58@@ -145,13 +164,19 @@
59 :return: The context dictionary.
60 :rtype: dict.
61 """
62- server_host = ''
63+ server_host = get_maas_server_host()
64+ # Create the url and the url-data (POST parameters) used to turn off
65+ # PXE booting once the install of the node is finished.
66+ node_disable_pxe_url = absolute_reverse(
67+ 'metadata-anon-node-edit', args=['latest', node.system_id])
68+ node_disable_pxe_data = urlencode({'op': 'netboot_off'})
69 return {
70 'node': node,
71 'release': release,
72 'server_host': server_host,
73 'preseed_data': compose_preseed(node),
74- 'node_disable_pxe_url': '', # TODO.
75+ 'node_disable_pxe_url': node_disable_pxe_url,
76+ 'node_disable_pxe_data': node_disable_pxe_data,
77 }
78
79
80
81=== modified file 'src/maasserver/tests/test_preseed.py'
82--- src/maasserver/tests/test_preseed.py 2012-06-19 16:01:07 +0000
83+++ src/maasserver/tests/test_preseed.py 2012-06-20 16:31:19 +0000
84@@ -13,6 +13,7 @@
85 __all__ = []
86
87 import os
88+from pipes import quote
89
90 from django.conf import settings
91 from maasserver.models import Config
92@@ -304,10 +305,20 @@
93 context = get_preseed_context(node, release)
94 self.assertItemsEqual(
95 ['node', 'release', 'server_host', 'preseed_data',
96- 'node_disable_pxe_url'],
97+ 'node_disable_pxe_url', 'node_disable_pxe_data'],
98 context)
99
100
101+class TestPreseedTemplate(TestCase):
102+ """Tests for class:`PreseedTemplate`."""
103+
104+ def test_escape_shell(self):
105+ template = PreseedTemplate("{{var|escape.shell}}")
106+ var = "$ ! ()"
107+ observed = template.substitute(var=var)
108+ self.assertEqual(quote(var), observed)
109+
110+
111 class TestRenderPreseed(TestCase):
112 """Tests for `render_preseed`.
113
114
115=== modified file 'src/maasserver/tests/test_utils.py'
116--- src/maasserver/tests/test_utils.py 2012-04-30 16:26:38 +0000
117+++ src/maasserver/tests/test_utils.py 2012-06-20 16:31:19 +0000
118@@ -12,7 +12,14 @@
119 __metaclass__ = type
120 __all__ = []
121
122-from maasserver.utils import map_enum
123+from django.conf import settings
124+from django.core.urlresolvers import reverse
125+from maasserver.testing.factory import factory
126+from maasserver.testing.testcase import TestCase as DjangoTestCase
127+from maasserver.utils import (
128+ absolute_reverse,
129+ map_enum,
130+ )
131 from maastesting.testcase import TestCase
132
133
134@@ -49,3 +56,28 @@
135 THREE = 3
136
137 self.assertEqual({'ONE': 1, 'THREE': 3}, map_enum(Enum))
138+
139+
140+class TestAbsoluteReverse(DjangoTestCase):
141+
142+ def test_absolute_reverse_uses_DEFAULT_MAAS_URL(self):
143+ maas_url = 'http://%s' % factory.getRandomString()
144+ self.patch(settings, 'DEFAULT_MAAS_URL', maas_url)
145+ absolute_url = absolute_reverse('settings')
146+ expected_url = settings.DEFAULT_MAAS_URL + reverse('settings')
147+ self.assertEqual(expected_url, absolute_url)
148+
149+ def test_absolute_reverse_uses_kwargs(self):
150+ node = factory.make_node()
151+ self.patch(settings, 'DEFAULT_MAAS_URL', '')
152+ absolute_url = absolute_reverse(
153+ 'node-view', kwargs={'system_id': node.system_id})
154+ expected_url = reverse('node-view', args=[node.system_id])
155+ self.assertEqual(expected_url, absolute_url)
156+
157+ def test_absolute_reverse_uses_args(self):
158+ node = factory.make_node()
159+ self.patch(settings, 'DEFAULT_MAAS_URL', '')
160+ absolute_url = absolute_reverse('node-view', args=[node.system_id])
161+ expected_url = reverse('node-view', args=[node.system_id])
162+ self.assertEqual(expected_url, absolute_url)
163
164=== modified file 'src/maasserver/utils.py'
165--- src/maasserver/utils.py 2012-06-15 05:07:40 +0000
166+++ src/maasserver/utils.py 2012-06-20 16:31:19 +0000
167@@ -11,11 +11,17 @@
168
169 __metaclass__ = type
170 __all__ = [
171+ 'absolute_reverse',
172 'get_db_state',
173 'ignore_unused',
174 'map_enum',
175 ]
176
177+from urlparse import urljoin
178+
179+from django.conf import settings
180+from django.core.urlresolvers import reverse
181+
182
183 def get_db_state(instance, field_name):
184 """Get the persisted state of a given field for a given model instance.
185@@ -53,3 +59,18 @@
186 for key, value in vars(enum_class).items()
187 if not key.startswith('_')
188 }
189+
190+
191+def absolute_reverse(view_name, *args, **kwargs):
192+ """Return the absolute URL (i.e. including the URL scheme specifier and
193+ the network location of the MAAS server). Internally this method simply
194+ calls Django's 'reverse' method and prefixes the result of that call with
195+ the configured DEFAULT_MAAS_URL.
196+
197+ :param view_name: Django's view function name/reference or URL pattern
198+ name for which to compute the absolute URL.
199+ :param args: Positional arguments for Django's 'reverse' method.
200+ :param kwargs: Named arguments for Django's 'reverse' method.
201+ """
202+ return urljoin(
203+ settings.DEFAULT_MAAS_URL, reverse(view_name, *args, **kwargs))
204
205=== modified file 'src/metadataserver/api.py'
206--- src/metadataserver/api.py 2012-06-18 04:46:17 +0000
207+++ src/metadataserver/api.py 2012-06-20 16:31:19 +0000
208@@ -11,6 +11,7 @@
209
210 __metaclass__ = type
211 __all__ = [
212+ 'AnonMetaDataHandler',
213 'IndexHandler',
214 'MetaDataHandler',
215 'UserDataHandler',
216@@ -20,6 +21,7 @@
217 from django.conf import settings
218 from django.core.exceptions import PermissionDenied
219 from django.http import HttpResponse
220+from django.shortcuts import get_object_or_404
221 from maasserver.api import (
222 api_exported,
223 api_operations,
224@@ -37,6 +39,7 @@
225 )
226 from maasserver.models import (
227 MACAddress,
228+ Node,
229 SSHKey,
230 )
231 from metadataserver.models import (
232@@ -321,3 +324,20 @@
233 mimetype='application/octet-stream')
234 except NodeUserData.DoesNotExist:
235 raise MAASAPINotFound("No user data available for this node.")
236+
237+
238+@api_operations
239+class AnonMetaDataHandler(VersionIndexHandler):
240+ """Anonymous metadata."""
241+
242+ @api_exported('POST')
243+ def netboot_off(self, request, version=None, system_id=None):
244+ """Turn off netboot on the node.
245+
246+ A commissioning node can call this to turn off netbooting when
247+ it finishes installing itself.
248+ """
249+ node = get_object_or_404(Node, system_id=system_id)
250+ node.netboot = False
251+ node.save()
252+ return rc.ALL_OK
253
254=== modified file 'src/metadataserver/tests/test_api.py'
255--- src/metadataserver/tests/test_api.py 2012-06-18 05:09:25 +0000
256+++ src/metadataserver/tests/test_api.py 2012-06-20 16:31:19 +0000
257@@ -541,3 +541,15 @@
258 response = client.post(self.make_url('/latest/'), {'op': 'netboot_on'})
259 node = reload_object(node)
260 self.assertTrue(node.netboot, response)
261+
262+ def test_anonymous_netboot_off(self):
263+ node = factory.make_node(netboot=True)
264+ anon_netboot_off_url = self.make_url(
265+ '/latest/%s/edit/' % node.system_id)
266+ response = self.client.post(
267+ anon_netboot_off_url, {'op': 'netboot_off'})
268+ node = reload_object(node)
269+ self.assertEqual(
270+ (httplib.OK, False),
271+ (response.status_code, node.netboot),
272+ response)
273
274=== modified file 'src/metadataserver/urls.py'
275--- src/metadataserver/urls.py 2012-06-06 03:11:48 +0000
276+++ src/metadataserver/urls.py 2012-06-20 16:31:19 +0000
277@@ -20,6 +20,7 @@
278 )
279 from maasserver.api_auth import api_auth
280 from metadataserver.api import (
281+ AnonMetaDataHandler,
282 IndexHandler,
283 MetaDataHandler,
284 UserDataHandler,
285@@ -34,6 +35,10 @@
286 index_handler = Resource(IndexHandler, authentication=api_auth)
287
288
289+# Handlers for anonymous node operations.
290+meta_data_node_anon_handler = Resource(AnonMetaDataHandler)
291+
292+
293 # Handlers for anonymous random metadata access.
294 meta_data_by_mac_handler = Resource(MetaDataHandler)
295 user_data_by_mac_handler = Resource(UserDataHandler)
296@@ -56,6 +61,18 @@
297 url(r'', index_handler, name='metadata'),
298 )
299
300+# Anonymous random metadata access. These serve requests from the nodes
301+# which happen when the environment is so minimal that proper
302+# authenticated calls are not possible.
303+anon_patterns = patterns(
304+ '',
305+ # XXX: rvb 2012-06-20 bug=1015559: This method is accessible
306+ # without authentication. This is a security threat.
307+ url(
308+ r'(?P<version>[^/]+)/(?P<system_id>[\w\-]+)/edit/$',
309+ meta_data_node_anon_handler,
310+ name='metadata-anon-node-edit'),
311+ )
312
313 # Anonymous random metadata access keyed by MAC address. These won't
314 # work unless ALLOW_ANONYMOUS_METADATA_ACCESS is enabled, which you
315@@ -80,4 +97,4 @@
316 # URL patterns. The anonymous patterns are listed first because they're
317 # so recognizable: there's no chance of a regular metadata access being
318 # mistaken for one of these based on URL pattern match.
319-urlpatterns = by_mac_patterns + node_patterns
320+urlpatterns = anon_patterns + by_mac_patterns + node_patterns