server.rapi: Factorize RAPI user loading
[ganeti-local] / lib / server / rapi.py
1 #
2 #
3
4 # Copyright (C) 2006, 2007, 2008, 2009, 2010 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21 """Ganeti Remote API master script.
22
23 """
24
25 # pylint: disable=C0103,W0142
26
27 # C0103: Invalid name ganeti-watcher
28
29 import logging
30 import optparse
31 import sys
32 import os
33 import os.path
34 import errno
35
36 try:
37   from pyinotify import pyinotify # pylint: disable=E0611
38 except ImportError:
39   import pyinotify
40
41 from ganeti import asyncnotifier
42 from ganeti import constants
43 from ganeti import http
44 from ganeti import daemon
45 from ganeti import ssconf
46 from ganeti import luxi
47 from ganeti import serializer
48 from ganeti import compat
49 from ganeti import utils
50 from ganeti.rapi import connector
51
52 import ganeti.http.auth   # pylint: disable=W0611
53 import ganeti.http.server
54
55
56 class RemoteApiRequestContext(object):
57   """Data structure for Remote API requests.
58
59   """
60   def __init__(self):
61     self.handler = None
62     self.handler_fn = None
63     self.handler_access = None
64     self.body_data = None
65
66
67 class RemoteApiHandler(http.auth.HttpServerRequestAuthentication,
68                        http.server.HttpServerHandler):
69   """REST Request Handler Class.
70
71   """
72   AUTH_REALM = "Ganeti Remote API"
73
74   def __init__(self, user_fn, _client_cls=None):
75     """Initializes this class.
76
77     @type user_fn: callable
78     @param user_fn: Function receiving username as string and returning
79       L{http.auth.PasswordFileUser} or C{None} if user is not found
80
81     """
82     # pylint: disable=W0233
83     # it seems pylint doesn't see the second parent class there
84     http.server.HttpServerHandler.__init__(self)
85     http.auth.HttpServerRequestAuthentication.__init__(self)
86     self._client_cls = _client_cls
87     self._resmap = connector.Mapper()
88     self._user_fn = user_fn
89
90   @staticmethod
91   def FormatErrorMessage(values):
92     """Formats the body of an error message.
93
94     @type values: dict
95     @param values: dictionary with keys C{code}, C{message} and C{explain}.
96     @rtype: tuple; (string, string)
97     @return: Content-type and response body
98
99     """
100     return (http.HTTP_APP_JSON, serializer.DumpJson(values))
101
102   def _GetRequestContext(self, req):
103     """Returns the context for a request.
104
105     The context is cached in the req.private variable.
106
107     """
108     if req.private is None:
109       (HandlerClass, items, args) = \
110                      self._resmap.getController(req.request_path)
111
112       ctx = RemoteApiRequestContext()
113       ctx.handler = HandlerClass(items, args, req, _client_cls=self._client_cls)
114
115       method = req.request_method.upper()
116       try:
117         ctx.handler_fn = getattr(ctx.handler, method)
118       except AttributeError:
119         raise http.HttpNotImplemented("Method %s is unsupported for path %s" %
120                                       (method, req.request_path))
121
122       ctx.handler_access = getattr(ctx.handler, "%s_ACCESS" % method, None)
123
124       # Require permissions definition (usually in the base class)
125       if ctx.handler_access is None:
126         raise AssertionError("Permissions definition missing")
127
128       # This is only made available in HandleRequest
129       ctx.body_data = None
130
131       req.private = ctx
132
133     # Check for expected attributes
134     assert req.private.handler
135     assert req.private.handler_fn
136     assert req.private.handler_access is not None
137
138     return req.private
139
140   def AuthenticationRequired(self, req):
141     """Determine whether authentication is required.
142
143     """
144     return bool(self._GetRequestContext(req).handler_access)
145
146   def Authenticate(self, req, username, password):
147     """Checks whether a user can access a resource.
148
149     """
150     ctx = self._GetRequestContext(req)
151
152     user = self._user_fn(username)
153     if not (user and
154             self.VerifyBasicAuthPassword(req, username, password,
155                                          user.password)):
156       # Unknown user or password wrong
157       return False
158
159     if (not ctx.handler_access or
160         set(user.options).intersection(ctx.handler_access)):
161       # Allow access
162       return True
163
164     # Access forbidden
165     raise http.HttpForbidden()
166
167   def HandleRequest(self, req):
168     """Handles a request.
169
170     """
171     ctx = self._GetRequestContext(req)
172
173     # Deserialize request parameters
174     if req.request_body:
175       # RFC2616, 7.2.1: Any HTTP/1.1 message containing an entity-body SHOULD
176       # include a Content-Type header field defining the media type of that
177       # body. [...] If the media type remains unknown, the recipient SHOULD
178       # treat it as type "application/octet-stream".
179       req_content_type = req.request_headers.get(http.HTTP_CONTENT_TYPE,
180                                                  http.HTTP_APP_OCTET_STREAM)
181       if req_content_type.lower() != http.HTTP_APP_JSON.lower():
182         raise http.HttpUnsupportedMediaType()
183
184       try:
185         ctx.body_data = serializer.LoadJson(req.request_body)
186       except Exception:
187         raise http.HttpBadRequest(message="Unable to parse JSON data")
188     else:
189       ctx.body_data = None
190
191     try:
192       result = ctx.handler_fn()
193     except luxi.TimeoutError:
194       raise http.HttpGatewayTimeout()
195     except luxi.ProtocolError, err:
196       raise http.HttpBadGateway(str(err))
197     except:
198       method = req.request_method.upper()
199       logging.exception("Error while handling the %s request", method)
200       raise
201
202     req.resp_headers[http.HTTP_CONTENT_TYPE] = http.HTTP_APP_JSON
203
204     return serializer.DumpJson(result)
205
206
207 class RapiUsers:
208   def __init__(self):
209     """Initializes this class.
210
211     """
212     self._users = None
213
214   def Get(self, username):
215     """Checks whether a user exists.
216
217     """
218     if self._users:
219       return self._users.get(username, None)
220     else:
221       return None
222
223   def Load(self, filename):
224     """Loads a file containing users and passwords.
225
226     @type filename: string
227     @param filename: Path to file
228
229     """
230     logging.info("Reading users file at %s", filename)
231     try:
232       try:
233         contents = utils.ReadFile(filename)
234       except EnvironmentError, err:
235         self._users = None
236         if err.errno == errno.ENOENT:
237           logging.warning("No users file at %s", filename)
238         else:
239           logging.warning("Error while reading %s: %s", filename, err)
240         return False
241
242       users = http.auth.ParsePasswordFile(contents)
243
244     except Exception, err: # pylint: disable=W0703
245       # We don't care about the type of exception
246       logging.error("Error while parsing %s: %s", filename, err)
247       return False
248
249     self._users = users
250
251     return True
252
253
254 class FileEventHandler(asyncnotifier.FileEventHandlerBase):
255   def __init__(self, wm, path, cb):
256     """Initializes this class.
257
258     @param wm: Inotify watch manager
259     @type path: string
260     @param path: File path
261     @type cb: callable
262     @param cb: Function called on file change
263
264     """
265     asyncnotifier.FileEventHandlerBase.__init__(self, wm)
266
267     self._cb = cb
268     self._filename = os.path.basename(path)
269
270     # Different Pyinotify versions have the flag constants at different places,
271     # hence not accessing them directly
272     mask = (pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"] |
273             pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"] |
274             pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_FROM"] |
275             pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_TO"])
276
277     self._handle = self.AddWatch(os.path.dirname(path), mask)
278
279   def process_default(self, event):
280     """Called upon inotify event.
281
282     """
283     if event.name == self._filename:
284       logging.debug("Received inotify event %s", event)
285       self._cb()
286
287
288 def SetupFileWatcher(filename, cb):
289   """Configures an inotify watcher for a file.
290
291   @type filename: string
292   @param filename: File to watch
293   @type cb: callable
294   @param cb: Function called on file change
295
296   """
297   wm = pyinotify.WatchManager()
298   handler = FileEventHandler(wm, filename, cb)
299   asyncnotifier.AsyncNotifier(wm, default_proc_fun=handler)
300
301
302 def CheckRapi(options, args):
303   """Initial checks whether to run or exit with a failure.
304
305   """
306   if args: # rapi doesn't take any arguments
307     print >> sys.stderr, ("Usage: %s [-f] [-d] [-p port] [-b ADDRESS]" %
308                           sys.argv[0])
309     sys.exit(constants.EXIT_FAILURE)
310
311   ssconf.CheckMaster(options.debug)
312
313   # Read SSL certificate (this is a little hackish to read the cert as root)
314   if options.ssl:
315     options.ssl_params = http.HttpSslParams(ssl_key_path=options.ssl_key,
316                                             ssl_cert_path=options.ssl_cert)
317   else:
318     options.ssl_params = None
319
320
321 def PrepRapi(options, _):
322   """Prep remote API function, executed with the PID file held.
323
324   """
325   mainloop = daemon.Mainloop()
326
327   users = RapiUsers()
328
329   handler = RemoteApiHandler(users.Get)
330
331   # Setup file watcher (it'll be driven by asyncore)
332   SetupFileWatcher(constants.RAPI_USERS_FILE,
333                    compat.partial(users.Load, constants.RAPI_USERS_FILE))
334
335   users.Load(constants.RAPI_USERS_FILE)
336
337   server = \
338     http.server.HttpServer(mainloop, options.bind_address, options.port,
339       handler, ssl_params=options.ssl_params, ssl_verify_peer=False)
340   server.Start()
341
342   return (mainloop, server)
343
344
345 def ExecRapi(options, args, prep_data): # pylint: disable=W0613
346   """Main remote API function, executed with the PID file held.
347
348   """
349   (mainloop, server) = prep_data
350   try:
351     mainloop.Run()
352   finally:
353     server.Stop()
354
355
356 def Main():
357   """Main function.
358
359   """
360   parser = optparse.OptionParser(description="Ganeti Remote API",
361                     usage="%prog [-f] [-d] [-p port] [-b ADDRESS]",
362                     version="%%prog (ganeti) %s" % constants.RELEASE_VERSION)
363
364   daemon.GenericMain(constants.RAPI, parser, CheckRapi, PrepRapi, ExecRapi,
365                      default_ssl_cert=constants.RAPI_CERT_FILE,
366                      default_ssl_key=constants.RAPI_CERT_FILE)