Package flumotion :: Package component :: Package base :: Module http
[hide private]

Source Code for Module flumotion.component.base.http

  1  # -*- Mode: Python; test-case-name: flumotion.test.test_http -*- 
  2  # vi:si:et:sw=4:sts=4:ts=4 
  3  # 
  4  # Flumotion - a streaming media server 
  5  # Copyright (C) 2004,2005,2006,2007 Fluendo, S.L. (www.fluendo.com). 
  6  # All rights reserved. 
  7   
  8  # This file may be distributed and/or modified under the terms of 
  9  # the GNU General Public License version 2 as published by 
 10  # the Free Software Foundation. 
 11  # This file is distributed without any warranty; without even the implied 
 12  # warranty of merchantability or fitness for a particular purpose. 
 13  # See "LICENSE.GPL" in the source distribution for more information. 
 14   
 15  # Licensees having purchased or holding a valid Flumotion Advanced 
 16  # Streaming Server license may use this file in accordance with the 
 17  # Flumotion Advanced Streaming Server Commercial License Agreement. 
 18  # See "LICENSE.Flumotion" in the source distribution for more information. 
 19   
 20  # Headers in this file shall remain intact. 
 21   
 22  import struct 
 23  import socket 
 24   
 25  from twisted.web import http, server 
 26  from twisted.web import resource as web_resource 
 27  from twisted.internet import reactor, defer 
 28  from twisted.python import reflect, failure 
 29   
 30  from flumotion.configure import configure 
 31  from flumotion.common import errors 
 32  from flumotion.twisted.credentials import cryptChallenge 
 33   
 34  from flumotion.common import common, log, keycards 
 35   
 36  #__all__ = ['HTTPStreamingResource', 'MultifdSinkStreamer'] 
 37  __version__ = "$Rev: 8058 $" 
 38   
 39   
 40  HTTP_SERVER_NAME = 'FlumotionHTTPServer' 
 41  HTTP_SERVER_VERSION = configure.version 
 42   
 43  ERROR_TEMPLATE = """<!doctype html public "-//IETF//DTD HTML 2.0//EN"> 
 44  <html> 
 45  <head> 
 46    <title>%(code)d %(error)s</title> 
 47  </head> 
 48  <body> 
 49  <h2>%(code)d %(error)s</h2> 
 50  </body> 
 51  </html> 
 52  """ 
 53   
 54  HTTP_SERVER = '%s/%s' % (HTTP_SERVER_NAME, HTTP_SERVER_VERSION) 
 55   
 56  ### This is new Issuer code that eventually should move to e.g. 
 57  ### flumotion.common.keycards or related 
 58   
 59   
60 -class Issuer(log.Loggable):
61 """ 62 I am a base class for all Issuers. 63 An issuer issues keycards of a given class based on an object 64 (incoming HTTP request, ...) 65 """ 66
67 - def issue(self, *args, **kwargs):
68 """ 69 Return a keycard, or None, based on the given arguments. 70 """ 71 raise NotImplementedError
72 73
74 -class HTTPGenericIssuer(Issuer):
75 """ 76 I create L{flumotion.common.keycards.Keycard} based on just a 77 standard HTTP request. Useful for authenticating based on 78 server-side checks such as time, rather than client credentials. 79 """ 80
81 - def issue(self, request):
82 keycard = keycards.KeycardGeneric() 83 self.debug("Asking for authentication, generic HTTP") 84 return keycard
85 86
87 -class HTTPAuthIssuer(Issuer):
88 """ 89 I create L{flumotion.common.keycards.KeycardUACPP} keycards based on 90 an incoming L{twisted.protocols.http.Request} request's standard 91 HTTP authentication information. 92 """ 93
94 - def issue(self, request):
95 # for now, we're happy with a UACPP keycard; the password arrives 96 # plaintext anyway 97 keycard = keycards.KeycardUACPP( 98 request.getUser(), 99 request.getPassword(), request.getClientIP()) 100 self.debug('Asking for authentication, user %s, password %s, ip %s' % ( 101 keycard.username, keycard.password, keycard.address)) 102 return keycard
103 104
105 -class HTTPTokenIssuer(Issuer):
106 """ 107 I create L{flumotion.common.keycards.KeycardToken} keycards based on 108 an incoming L{twisted.protocols.http.Request} request's GET "token" 109 parameter. 110 """ 111
112 - def issue(self, request):
113 if not 'token' in request.args.keys(): 114 return None 115 116 # args can have lists as values, if more than one specified 117 token = request.args['token'] 118 if not isinstance(token, str): 119 token = token[0] 120 121 keycard = keycards.KeycardToken(token, 122 request.getClientIP(), request.path) 123 return keycard
124 125
126 -class HTTPGetArgumentsIssuer(Issuer):
127 """ 128 I create L{flumotion.common.keycards.KeycardHTTPGetArguments} 129 keycards based on an incoming L{twisted.protocols.http.Request}. 130 """ 131
132 - def issue(self, request):
133 arguments = request.args 134 address = request.getClientIP() 135 path = request.path 136 return keycards.KeycardHTTPGetArguments(arguments, address, path)
137 138 139 BOUNCER_SOCKET = 'flumotion.component.bouncers.plug.BouncerPlug' 140 141
142 -class HTTPAuthentication(log.Loggable):
143 """ 144 Helper object for handling HTTP authentication for twisted.web 145 Resources, using issuers and bouncers. 146 """ 147 148 logCategory = 'httpauth' 149 150 KEYCARD_TTL = 60 * 60 151 KEYCARD_KEEPALIVE_INTERVAL = 20 * 60 152 KEYCARD_TRYAGAIN_INTERVAL = 1 * 60 153
154 - def __init__(self, component):
155 self.component = component 156 self._fdToKeycard = {} # request fd -> Keycard 157 self._idToKeycard = {} # keycard id -> Keycard 158 self._fdToDurationCall = {} # request fd -> IDelayedCall 159 # for duration 160 self._domain = None # used for auth challenge and on keycard 161 self._issuer = HTTPAuthIssuer() # issues keycards; default for compat 162 self.bouncerName = None 163 self.setRequesterId(component.getName()) 164 self._defaultDuration = None # default duration to use if the keycard 165 # doesn't specify one. 166 self._pendingCleanups = [] 167 self._keepAlive = None 168 169 if (BOUNCER_SOCKET in self.component.plugs 170 and self.component.plugs[BOUNCER_SOCKET]): 171 assert len(self.component.plugs[BOUNCER_SOCKET]) == 1 172 self.plug = self.component.plugs[BOUNCER_SOCKET][0] 173 else: 174 self.plug = None
175
176 - def scheduleKeepAlive(self, tryingAgain=False):
177 178 def timeout(): 179 180 def reschedule(res): 181 if isinstance(res, failure.Failure): 182 self.info('keepAlive failed, rescheduling in %d ' 183 'seconds', self.KEYCARD_TRYAGAIN_INTERVAL) 184 self._keepAlive = None 185 self.scheduleKeepAlive(tryingAgain=True) 186 else: 187 self.info('keepAlive successful') 188 self._keepAlive = None 189 self.scheduleKeepAlive(tryingAgain=False)
190 191 if self.bouncerName is not None: 192 self.debug('calling keepAlive on bouncer %s', 193 self.bouncerName) 194 d = self.keepAlive(self.bouncerName, self.issuerName, 195 self.KEYCARD_TTL) 196 d.addCallbacks(reschedule, reschedule) 197 else: 198 self.scheduleKeepAlive()
199 200 if tryingAgain: 201 self._keepAlive = reactor.callLater( 202 self.KEYCARD_TRYAGAIN_INTERVAL, timeout) 203 else: 204 self._keepAlive = reactor.callLater( 205 self.KEYCARD_KEEPALIVE_INTERVAL, timeout) 206
207 - def stopKeepAlive(self):
208 if self._keepAlive is not None: 209 self._keepAlive.cancel() 210 self._keepAlive = None
211
212 - def setDomain(self, domain):
213 """ 214 Set a domain name on the resource, used in HTTP auth challenges and 215 on the keycard. 216 217 @type domain: string 218 """ 219 self._domain = domain
220
221 - def setBouncerName(self, bouncerName):
222 self.bouncerName = bouncerName
223
224 - def setRequesterId(self, requesterId):
225 self.requesterId = requesterId 226 # make something uniquey 227 self.issuerName = str(self.requesterId) + '-' + cryptChallenge()
228
229 - def setDefaultDuration(self, defaultDuration):
230 self._defaultDuration = defaultDuration
231
232 - def setIssuerClass(self, issuerClass):
233 # FIXME: in the future, we want to make this pluggable and have it 234 # look up somewhere ? 235 if issuerClass == 'HTTPTokenIssuer': 236 self._issuer = HTTPTokenIssuer() 237 elif issuerClass == 'HTTPGetArgumentsIssuer': 238 self._issuer = HTTPGetArgumentsIssuer() 239 elif issuerClass == 'HTTPAuthIssuer': 240 self._issuer = HTTPAuthIssuer() 241 elif issuerClass == 'HTTPGenericIssuer': 242 self._issuer = HTTPGenericIssuer() 243 else: 244 raise ValueError("issuerClass %s not accepted" % issuerClass)
245
246 - def authenticate(self, request):
247 """ 248 Returns: a deferred returning a keycard or None 249 """ 250 keycard = self._issuer.issue(request) 251 if not keycard: 252 self.debug('no keycard from issuer, firing None') 253 return defer.succeed(None) 254 255 keycard.requesterId = self.requesterId 256 keycard.issuerName = self.issuerName 257 keycard._fd = request.transport.fileno() 258 keycard.domain = self._domain 259 260 if self.plug: 261 self.debug('authenticating against plug') 262 return self.plug.authenticate(keycard) 263 elif self.bouncerName == None: 264 self.debug('no bouncer, accepting') 265 return defer.succeed(keycard) 266 else: 267 keycard.ttl = self.KEYCARD_TTL 268 self.debug('sending keycard to remote bouncer %r', 269 self.bouncerName) 270 return self.authenticateKeycard(self.bouncerName, keycard)
271
272 - def authenticateKeycard(self, bouncerName, keycard):
273 return self.component.medium.authenticate(bouncerName, keycard)
274
275 - def keepAlive(self, bouncerName, issuerName, ttl):
276 return self.component.medium.keepAlive(bouncerName, issuerName, ttl)
277
278 - def cleanupKeycard(self, bouncerName, keycard):
279 return self.component.medium.removeKeycardId(bouncerName, keycard.id)
280 281 # FIXME: check this 282
283 - def clientDone(self, fd):
284 return self.component.remove_client(fd)
285
286 - def doCleanupKeycard(self, bouncerName, keycard):
287 # cleanup this one keycard, and take the opportunity to retry 288 # previous failed cleanups 289 290 def cleanup(bouncerName, keycard): 291 292 def cleanupLater(res, pair): 293 self.log('failed to clean up keycard %r, will do ' 294 'so later', keycard) 295 self._pendingCleanups.append(pair)
296 d = self.cleanupKeycard(bouncerName, keycard) 297 d.addErrback(cleanupLater, (bouncerName, keycard)) 298 pending = self._pendingCleanups 299 self._pendingCleanups = [] 300 cleanup(bouncerName, keycard) 301 for bouncerName, keycard in pending: 302 cleanup(bouncerName, keycard) 303 304 # public 305
306 - def cleanupAuth(self, fd):
307 if self.bouncerName and fd in self._fdToKeycard: 308 keycard = self._fdToKeycard[fd] 309 self.debug('[fd %5d] asking bouncer %s to remove keycard id %s', 310 fd, self.bouncerName, keycard.id) 311 self.doCleanupKeycard(self.bouncerName, keycard) 312 self._removeKeycard(fd)
313
314 - def _removeKeycard(self, fd):
315 if self.bouncerName and fd in self._fdToKeycard: 316 keycard = self._fdToKeycard[fd] 317 del self._fdToKeycard[fd] 318 del self._idToKeycard[keycard.id] 319 if fd in self._fdToDurationCall: 320 self.debug('[fd %5d] canceling later expiration call' % fd) 321 self._fdToDurationCall[fd].cancel() 322 del self._fdToDurationCall[fd]
323
324 - def _durationCallLater(self, fd):
325 """ 326 Expire a client due to a duration expiration. 327 """ 328 self.debug('[fd %5d] duration exceeded, expiring client' % fd) 329 330 # we're called from a callLater, so we've already run; just delete 331 if fd in self._fdToDurationCall: 332 del self._fdToDurationCall[fd] 333 334 self.debug('[fd %5d] asking streamer to remove client' % fd) 335 self.clientDone(fd)
336
337 - def expireKeycard(self, keycardId):
338 """ 339 Expire a client's connection associated with the keycard Id. 340 """ 341 keycard = self._idToKeycard[keycardId] 342 fd = keycard._fd 343 344 self.debug('[fd %5d] expiring client' % fd) 345 346 self._removeKeycard(fd) 347 348 self.debug('[fd %5d] asking streamer to remove client' % fd) 349 self.clientDone(fd)
350
351 - def expireKeycards(self, keycardIds):
352 """ 353 Expire client's connections associated with the keycard Ids. 354 """ 355 expired = 0 356 for keycardId in keycardIds: 357 try: 358 self.expireKeycard(keycardId) 359 expired += 1 360 except: 361 pass 362 return expired
363 364 ### resource.Resource methods 365
366 - def startAuthentication(self, request):
367 d = self.authenticate(request) 368 d.addCallback(self._authenticatedCallback, request) 369 d.addErrback(self._authenticatedErrback, request) 370 d.addErrback(self._defaultErrback, request) 371 372 return d
373
374 - def _authenticatedCallback(self, keycard, request):
375 # !: since we are a callback, the incoming fd might have gone away 376 # and closed 377 self.debug('_authenticatedCallback: keycard %r' % keycard) 378 if not keycard: 379 raise errors.NotAuthenticatedError() 380 381 # properly authenticated 382 if request.method == 'GET': 383 fd = request.transport.fileno() 384 385 if self.bouncerName: 386 # the request was finished before the callback was executed 387 if fd == -1: 388 self.debug('Request interrupted before authentification ' 389 'was finished: asking bouncer %s to remove ' 390 'keycard id %s', self.bouncerName, keycard.id) 391 self.doCleanupKeycard(self.bouncerName, keycard) 392 return None 393 if keycard.id in self._idToKeycard: 394 self.warning("Duplicate keycard id: refusing") 395 raise errors.NotAuthenticatedError() 396 397 self._fdToKeycard[fd] = keycard 398 self._idToKeycard[keycard.id] = keycard 399 400 duration = keycard.duration or self._defaultDuration 401 402 if duration: 403 self.debug('new connection on %d will expire in %f seconds' % ( 404 fd, duration)) 405 self._fdToDurationCall[fd] = reactor.callLater( 406 duration, self._durationCallLater, fd) 407 408 return None
409
410 - def _authenticatedErrback(self, failure, request):
411 failure.trap(errors.UnknownComponentError, 412 errors.NotAuthenticatedError) 413 self._handleUnauthorized(request, http.UNAUTHORIZED) 414 return failure
415
416 - def _defaultErrback(self, failure, request):
417 if failure.check(errors.UnknownComponentError, 418 errors.NotAuthenticatedError) is None: 419 # If something else went wrong, we want to disconnect the client 420 # and give them a 500 Internal Server Error. 421 self._handleUnauthorized(request, http.INTERNAL_SERVER_ERROR) 422 return failure
423
424 - def _handleUnauthorized(self, request, code):
425 self.debug('client from %s is unauthorized, returning code %r' % 426 (request.getClientIP(), code)) 427 request.setHeader('content-type', 'text/html') 428 request.setHeader('server', HTTP_SERVER_VERSION) 429 if self._domain and code == http.UNAUTHORIZED: 430 request.setHeader('WWW-Authenticate', 431 'Basic realm="%s"' % self._domain) 432 433 request.setResponseCode(code) 434 435 # we have to write data ourselves, 436 # since we already returned NOT_DONE_YET 437 html = ERROR_TEMPLATE % {'code': code, 438 'error': http.RESPONSES[code]} 439 request.write(html) 440 request.finish()
441 442
443 -class LogFilter:
444
445 - def __init__(self):
446 self.filters = [] # list of (network, mask)
447
448 - def addIPFilter(self, filter):
449 """ 450 Add an IP filter of the form IP/prefix-length (CIDR syntax), or just 451 a single IP address 452 """ 453 definition = filter.split('/') 454 if len(definition) == 2: 455 (net, prefixlen) = definition 456 prefixlen = int(prefixlen) 457 elif len(definition) == 1: 458 net = definition[0] 459 prefixlen = 32 460 else: 461 raise errors.ConfigError( 462 "Cannot parse filter definition %s" % filter) 463 464 if prefixlen < 0 or prefixlen > 32: 465 raise errors.ConfigError("Invalid prefix length") 466 467 mask = ~((1 << (32 - prefixlen)) - 1) 468 try: 469 net = struct.unpack(">I", socket.inet_pton(socket.AF_INET, net))[0] 470 except socket.error: 471 raise errors.ConfigError( 472 "Failed to parse network address %s" % net) 473 net = net & mask # just in case 474 475 self.filters.append((net, mask))
476
477 - def isInRange(self, ip):
478 """ 479 Return true if ip is in any of the defined network(s) for this filter 480 """ 481 # Handles IPv4 only. 482 realip = struct.unpack(">I", socket.inet_pton(socket.AF_INET, ip))[0] 483 for f in self.filters: 484 if (realip & f[1]) == f[0]: 485 return True 486 return False
487