For the last week or so I have been using calypso directly loaded from Apache. The patch for this is quite intrusive so I'd like to give it some more testing before sending it out for inclusion upstream, but it's been working fine for me over the last couple of days.
It moves all general app functionality into a CalypsoApp() class and then changes the current standalone server to call out to that, as well as allowing the CalypsoApp() to be used directly by e.g. mod_wsgi. Cheers, Jelmer
commit c43fcf36af42149ca01edb7aa200d92b7feb1a55 Author: Jelmer Vernooij <[email protected]> Date: Sat Apr 9 20:54:39 2016 +0000 Refactor the CollectionHTTPServer around a wsgi-style request handler. This makes it possible to use calypso as a wsgi app. diff --git a/README b/README index 398e000..09c9f6c 100644 --- a/README +++ b/README @@ -90,3 +90,17 @@ the put the service name to use into ~/.config/calypso/config: and install the pykerberos module. You should then be able to authenticate via Kerberos using GSSAPI. + +Using from WSGI +--------------- + +Calypso can either be run as its own independent server, or from +a WSGI server like Apache with mod_wsgi. To run it from Apache, +enable mod_wsgi (by running "a2enmod wsgi") and set something like: + +WSGIScriptAlias /dav /home/example/src/calypso/wsgi.py +# If calypso is not in the system python Path: +# WSGIPythonPath /home/example/src/calypso +WSGIDaemonProcess foo.example.com user=username processes=5 threads=4 display-name=%{GROUP} +WSGIProcessGroup foo.example.com +WSGIPassAuthorization On diff --git a/calypso/__init__.py b/calypso/__init__.py index da02962..4e57b2f 100644 --- a/calypso/__init__.py +++ b/calypso/__init__.py @@ -22,18 +22,20 @@ """ Calypso Server module. -This module offers 3 useful classes: +This module offers 4 useful classes: - ``HTTPServer`` is a simple HTTP server; - ``HTTPSServer`` is a HTTPS server, wrapping the HTTP server in a socket managing SSL connections; - ``CollectionHTTPHandler`` is a WebDAV request handler for HTTP(S) servers. +- ``CalypsoApp`` is a WSGI-style HTTP request handler To use this module, you should take a look at the file ``calypso.py`` that should have been included in this package. """ +from cStringIO import StringIO import os import os.path import base64 @@ -64,37 +66,47 @@ negotiate = gssapi.Negotiate(log) VERSION = "1.5" -def _check(request, function): +def _check(request, function, environ, start_response): """Check if user has sufficient rights for performing ``request``.""" # ``_check`` decorator can access ``request`` protected functions # pylint: disable=W0212 owner = user = password = None negotiate_success = False - if request._collection: - owner = request._collection.owner + collection = collection_singleton(environ['PATH_INFO']) + if collection: + owner = collection.owner - authorization = request.headers.get("Authorization", None) + # When running as part of another server (e.g. Apache) allow + # authentication there. + if "REMOTE_USER" in environ: + user = environ["REMOTE_USER"] + + authorization = environ.get("HTTP_AUTHORIZATION", None) if authorization: if authorization.startswith("Basic"): challenge = authorization.lstrip("Basic").strip().encode("ascii") - plain = request._decode(base64.b64decode(challenge)) + plain = request._decode(environ.get('CONTENT_TYPE', ''), base64.b64decode(challenge)) user, password = plain.split(":") elif negotiate.enabled(): user, negotiate_success = negotiate.try_aaa(authorization, request, owner) # Also send UNAUTHORIZED if there's no collection. Otherwise one # could probe the server for (non-)existing collections. - if request.server.acl.has_right(owner, user, password) or negotiate_success: - function(request, context={"user": user, "user-agent": request.headers.get("User-Agent", None)}) + if request.acl.has_right(owner, user, password) or negotiate_success: + return function(request, context={ + "user": user, + "user-agent": environ.get("HTTP_USER_AGENT", None)}, + environ=environ, start_response=start_response, + ) else: - request.send_calypso_response(client.UNAUTHORIZED, 0) if negotiate.enabled(): - request.send_header("WWW-Authenticate", "Negotiate") - request.send_header( - "WWW-Authenticate", - 'Basic realm="Calypso CalDAV/CardDAV server - password required"') - request.end_headers() + start_response('401 Unauthorized', [ + ('WWW-Authenticate', 'Negotiate')]) + else: + start_response('401 Unauthorized', [ + ("WWW-Authenticate", + 'Basic realm="Calypso CalDAV/CardDAV server - password required"')]) # pylint: enable=W0212 @@ -107,7 +119,6 @@ class HTTPServer(server.HTTPServer): def __init__(self, address, handler): """Create server.""" server.HTTPServer.__init__(self, address, handler) - self.acl = acl.load() # pylint: enable=W0231 @@ -128,137 +139,42 @@ class HTTPSServer(HTTPServer): self.server_activate() -class CollectionHTTPHandler(server.BaseHTTPRequestHandler): - """HTTP requests handler for WebDAV collections.""" - _encoding = config.get("encoding", "request") - - # Decorator checking rights before performing request - check_rights = lambda function: lambda request: _check(request, function) - - # We do set Content-Length on all replies, so we can use HTTP/1.1 - # with multiple requests (as desired by the android CalDAV sync program - - protocol_version = 'HTTP/1.1' - - timeout = 90 - - server_version = "Calypso/%s" % VERSION - queued_headers = {} - - def queue_header(self, keyword, value): - self.queued_headers[keyword] = value - - def end_headers(self): - """ - Send out all queued headers and invoke or super classes - end_header. - """ - if self.queued_headers: - for keyword, val in self.queued_headers.items(): - self.send_header(keyword, val) - self.queued_headers = {} - return server.BaseHTTPRequestHandler.end_headers(self) +def collection_singleton(p): + path = paths.collection_from_path(p) + if not path: + return None + if not path in CalypsoApp.collections: + CalypsoApp.collections[path] = webdav.Collection(path) + return CalypsoApp.collections[path] - def address_string(self): - return str(self.client_address[0]) - def send_connection_header(self): - conntype = "Close" - if self.close_connection == 0: - conntype = "Keep-Alive" - self.send_header("Connection", conntype) - - def send_calypso_response(self, response, length): - self.send_response(response) - self.send_connection_header() - self.send_header("Content-Length", length) - for header, value in config.items('headers'): - self.send_header(header, value) - - - def handle_one_request(self): - """Handle a single HTTP request. - - You normally don't need to override this method; see the class - __doc__ string for information on how to handle specific HTTP - commands such as GET and POST. - - """ - try: - self.wfile.flush() - self.close_connection = 1 - - self.connection.settimeout(5) +class CalypsoApp(object): - self.raw_requestline = self.rfile.readline(65537) + _encoding = config.get("encoding", "request") - self.connection.settimeout(90) + # Decorator checking rights before performing request + check_rights = lambda function: lambda request, environ, start_response: _check(request, function, environ, start_response) - if len(self.raw_requestline) > 65536: - log.error("Read request too long") - self.requestline = '' - self.request_version = '' - self.command = '' - self.send_error(414) - return - if not self.raw_requestline: - log.error("Connection closed") - return - log.debug("First line '%s'", self.raw_requestline) - if not self.parse_request(): - # An error code has been sent, just exit - self.close_connection = 1 - return - # parse_request clears close_connection on all http/1.1 links - # it should only do this if a keep-alive header is seen - self.close_connection = 1 - conntype = self.headers.get('Connection', "") - if (conntype.lower() == 'keep-alive' - and self.protocol_version >= "HTTP/1.1"): - log.debug("keep-alive") - self.close_connection = 0 - reqlen = self.headers.get('Content-Length',"0") - log.debug("reqlen %s", reqlen) - self.xml_request = self.rfile.read(int(reqlen)) - mname = 'do_' + self.command - if not hasattr(self, mname): - log.error("Unsupported method (%r)", self.command) - self.send_error(501, "Unsupported method (%r)" % self.command) - return - method = getattr(self, mname) - method() - self.wfile.flush() #actually send the response if not already done. - except socket.timeout as e: - #a read or a write timed out. Discard this connection - log.error("Request timed out: %r", e) - self.close_connection = 1 - return - except ssl.SSLError, x: - #an io error. Discard this connection - log.error("SSL request error: %r", x.args[0]) - self.close_connection = 1 - return + def __init__(self): + self.acl = acl.load() + def __call__(self, environ, start_response): + mname = 'do_' + environ['REQUEST_METHOD'] + if not hasattr(self, mname): + log.error("Unsupported method (%r)", self.command) + start_response('501 Unknown method', []) + return ["Unsupported method (%r)" % self.command] + method = getattr(self, mname) + return method(environ, start_response) collections = {} - @property - def _collection(self): - """The ``webdav.Collection`` object corresponding to the given path.""" - path = paths.collection_from_path(self.path) - if not path: - return None - if not path in CollectionHTTPHandler.collections: - CollectionHTTPHandler.collections[path] = webdav.Collection(path) - return CollectionHTTPHandler.collections[path] - - def _decode(self, text): + def _decode(self, content_type, text): """Try to decode text according to various parameters.""" # List of charsets to try charsets = [] # First append content charset given in the request - content_type = self.headers.get("Content-Type", None) if content_type and "charset=" in content_type: charsets.append(content_type.split("charset=")[1].strip()) # Then append default Calypso charset @@ -279,65 +195,66 @@ class CollectionHTTPHandler(server.BaseHTTPRequestHandler): # pylint: disable=C0103 @check_rights - def do_GET(self, context): + def do_GET(self, context, environ, start_response): """Manage GET request.""" - self.do_get_head(context, True) + return self.do_get_head(context, True, environ, start_response) @check_rights - def do_HEAD(self, context): + def do_HEAD(self, context, environ, start_response): """Manage HEAD request.""" - self.do_get_head(context, False) + return self.do_get_head(context, False, environ, start_response) - def do_get_head(self, context, is_get): + def do_get_head(self, context, is_get, environ, start_response): """Manage either GET or HEAD request.""" - self._answer = '' + path = environ['PATH_INFO'] answer_text = '' try: - item_name = paths.resource_from_path(self.path) - if item_name and self._collection: + item_name = paths.resource_from_path(path) + collection = collection_singleton(path) + if item_name and collection: # Get collection item - item = self._collection.get_item(item_name) + item = collection.get_item(item_name) if item: if is_get: answer_text = item.text etag = item.etag else: - self.send_response(client.GONE) - self.send_header("Content-Length", 0) - self.end_headers() - return - elif self._collection: + start_response('410 Gone', [('Content-Length', '0')]) + return [] + elif collection: # Get whole collection if is_get: - answer_text = self._collection.text - etag = self._collection.etag + answer_text = collection.text + etag = collection.etag else: - self.send_calypso_response(client.NOT_FOUND, 0) - self.end_headers() - return - + start_response('404 Not Found', []) + return [] + if is_get: try: - self._answer = answer_text.encode(self._encoding,"xmlcharrefreplace") + answer = answer_text.encode(self._encoding, "xmlcharrefreplace") except UnicodeDecodeError: answer_text = answer_text.decode(errors="ignore") - self._answer = answer_text.encode(self._encoding,"ignore") + answer = answer_text.encode(self._encoding,"ignore") + else: + answer = '' - self.send_calypso_response(client.OK, len(self._answer)) - self.send_header("Content-Type", "text/calendar") - self.send_header("Last-Modified", email.utils.formatdate(time.mktime(self._collection.last_modified))) - self.send_header("ETag", etag) - self.end_headers() + start_response('200 OK', [ + ('Content-Length', str(len(answer))), + ("Content-Type", "text/calendar"), + ("Last-Modified", email.utils.formatdate(time.mktime(collection.last_modified))), + ("ETag", etag)]) if is_get: - self.wfile.write(self._answer) + return [answer] + return [] except Exception: - log.exception("Failed HEAD for %s", self.path) - self.send_calypso_response(client.BAD_REQUEST, 0) - self.end_headers() + log.exception("Failed HEAD for %s", path) + start_response('400 Bad Request', []) + return [] - def if_match(self, item): - header = self.headers.get("If-Match", item.etag) + def if_match(self, environ, item): + header = environ.get("HTTP_IF_MATCH", item.etag) header = rfc822.unquote(header) if header == item.etag: return True @@ -350,129 +267,244 @@ class CollectionHTTPHandler(server.BaseHTTPRequestHandler): return False @check_rights - def do_DELETE(self, context): + def do_DELETE(self, context, environ, start_response): """Manage DELETE request.""" try: - item_name = paths.resource_from_path(self.path) - item = self._collection.get_item(item_name) + path = environ['PATH_INFO'] + item_name = paths.resource_from_path(path) + collection = collection_singleton(path) + item = collection.get_item(item_name) - if item and self.if_match(item): + if item and self.if_match(environ, item): # No ETag precondition or precondition verified, delete item - self._answer = xmlutils.delete(self.path, self._collection, context=context) - - self.send_calypso_response(client.NO_CONTENT, len(self._answer)) - self.send_header("Content-Type", "text/xml") - self.end_headers() - self.wfile.write(self._answer) + answer = xmlutils.delete(path, collection, context=context) + + start_response('200 OK', [ + ('Content-Length', str(len(answer))), + ('Content-Type', 'text/xml')]) + return [answer] elif not item: # Item does not exist - self.send_calypso_response(client.NOT_FOUND, 0) - self.end_headers() + start_response('404 Not Found', []) + return [] else: # No item or ETag precondition not verified, do not delete item - self.send_calypso_response(client.PRECONDITION_FAILED, 0) - self.end_headers() + start_response('412 Precondition Failed', []) + return [] except Exception: - log.exception("Failed DELETE for %s", self.path) - self.send_calypso_response(client.BAD_REQUEST, 0) - self.end_headers() + log.exception("Failed DELETE for %s", path) + start_response('400 Bad Request', []) + return [] @check_rights - def do_MKCALENDAR(self, context): + def do_MKCALENDAR(self, context, environ, start_response): """Manage MKCALENDAR request.""" - self.send_calypso_response(client.CREATED, 0) - self.end_headers() + start_response('201 Created', []) + return [] - def do_OPTIONS(self): + def do_OPTIONS(self, environ, start_response): """Manage OPTIONS request.""" - self.send_calypso_response(client.OK, 0) - self.send_header( - "Allow", "DELETE, HEAD, GET, MKCALENDAR, " - "OPTIONS, PROPFIND, PUT, REPORT") - self.send_header("DAV", "1, access-control, calendar-access, addressbook") - self.end_headers() + start_response('204 No Content', [ + ("Allow", "DELETE, HEAD, GET, MKCALENDAR, " + "OPTIONS, PROPFIND, PUT, REPORT"), + ("DAV", "1, access-control, calendar-access, addressbook")]) + return [] @check_rights - def do_PROPFIND(self, context): + def do_PROPFIND(self, context, environ, start_response): """Manage PROPFIND request.""" try: - xml_request = self.xml_request + path = environ['PATH_INFO'] + xml_request = environ['wsgi.input'].read(int(environ.get('CONTENT_LENGTH', '0'))) log.debug("PROPFIND %s", xml_request) - self._answer = xmlutils.propfind( - self.path, xml_request, self._collection, - self.headers.get("depth", "infinity"), + answer = xmlutils.propfind( + path, xml_request, collection_singleton(path), + environ.get("HTTP_DEPTH", "infinity"), context) - log.debug("PROPFIND ANSWER %s", self._answer) + log.debug("PROPFIND ANSWER %s", answer) - self.send_calypso_response(client.MULTI_STATUS, len(self._answer)) - self.send_header("DAV", "1, calendar-access") - self.send_header("Content-Type", "text/xml") - self.end_headers() - self.wfile.write(self._answer) + start_response('207 Multi-Status', [ + ('Content-Length', str(len(answer))), + ("DAV", "1, calendar-access"), + ("Content-Type", "text/xml")]) + + return [answer] except Exception: - log.exception("Failed PROPFIND for %s", self.path) - self.send_calypso_response(client.BAD_REQUEST, 0) - self.end_headers() + log.exception("Failed PROPFIND for %s", path) + start_response('400 Bad Request', []) + return [] @check_rights - def do_SEARCH(self, context): + def do_SEARCH(self, context, environ, start_response): """Manage SEARCH request.""" try: - self.send_calypso_response(client.NO_CONTENT, 0) - self.end_headers() + path = environ['PATH_INFO'] + start_response('204 No Content', []) + return [] except Exception: - log.exception("Failed SEARCH for %s", self.path) - self.send_calypso_response(client.BAD_REQUEST, 0) - self.end_headers() - + log.exception("Failed SEARCH for %s", path) + start_response('400 Bad Request', []) + return [] + @check_rights - def do_PUT(self, context): + def do_PUT(self, context, environ, start_response): """Manage PUT request.""" try: - item_name = paths.resource_from_path(self.path) - item = self._collection.get_item(item_name) - if not item or self.if_match(item): + path = environ['PATH_INFO'] + item_name = paths.resource_from_path(path) + collection = collection_singleton(path) + item = collection.get_item(item_name) + if not item or self.if_match(environ, item): # PUT allowed in 3 cases # Case 1: No item and no ETag precondition: Add new item # Case 2: Item and ETag precondition verified: Modify item # Case 3: Item and no Etag precondition: Force modifying item - webdav_request = self._decode(self.xml_request) - new_item = xmlutils.put(self.path, webdav_request, self._collection, context=context) - + content_type = environ.get("CONTENT_TYPE", None) + webdav_request = self._decode(content_type, self.xml_request) + new_item = xmlutils.put(path, webdav_request, collection, context=context) + log.debug("item_name %s new_name %s", item_name, new_item.name) etag = new_item.etag #log.debug("replacement etag %s", etag) - self.send_calypso_response(client.CREATED, 0) - self.send_header("ETag", etag) - self.end_headers() + start_response('201 Created', [ + ("ETag", etag), + ]) + return [] else: #log.debug("Precondition failed") # PUT rejected in all other cases - self.send_calypso_response(client.PRECONDITION_FAILED, 0) - self.end_headers() + start_response('412 Precondition Failed', []) + return [] except Exception: - log.exception('Failed PUT for %s', self.path) - self.send_calypso_response(client.BAD_REQUEST, 0) - self.end_headers() - + log.exception('Failed PUT for %s', path) + start_response('400 Bad Request', []) + return [] @check_rights - def do_REPORT(self, context): + def do_REPORT(self, context, environ, start_response): """Manage REPORT request.""" try: - xml_request = self.xml_request - log.debug("REPORT %s %s", self.path, xml_request) - self._answer = xmlutils.report(self.path, xml_request, self._collection) - log.debug("REPORT ANSWER %s", self._answer) - self.send_calypso_response(client.MULTI_STATUS, len(self._answer)) - self.send_header("Content-Type", "text/xml") - self.end_headers() - self.wfile.write(self._answer) + path = environ['PATH_INFO'] + xml_request = environ['wsgi.input'].read(int(environ['CONTENT_LENGTH'])) + log.debug("REPORT %s %s", path, xml_request) + collection = collection_singleton(path) + answer = xmlutils.report(path, xml_request, collection) + log.debug("REPORT ANSWER %s", answer) + start_response('207 Multi-Status', [ + ('Content-Length', str(len(answer))), + ("Content-Type", "text/xml"), + ]) + return [answer] except Exception: - log.exception("Failed REPORT for %s", self.path) - self.send_calypso_response(client.BAD_REQUEST, 0) - self.end_headers() + log.exception("Failed REPORT for %s", path) + start_response('400 Bad Request', []) + return [] # pylint: enable=C0103 + + +class CollectionHTTPHandler(server.BaseHTTPRequestHandler): + """HTTP requests handler for WebDAV collections.""" + + # We do set Content-Length on all replies, so we can use HTTP/1.1 + # with multiple requests (as desired by the android CalDAV sync program + + app = CalypsoApp() + + protocol_version = 'HTTP/1.1' + + timeout = 90 + + server_version = "Calypso/%s" % VERSION + + def address_string(self): + return str(self.client_address[0]) + + def send_connection_header(self): + conntype = "Close" + if self.close_connection == 0: + conntype = "Keep-Alive" + self.send_header("Connection", conntype) + + def send_calypso_response(self, response, length): + self.send_response(response) + self.send_connection_header() + self.send_header("Content-Length", str(length)) + for header, value in config.items('headers'): + self.send_header(header, value) + + def handle_one_request(self): + """Handle a single HTTP request. + + You normally don't need to override this method; see the class + __doc__ string for information on how to handle specific HTTP + commands such as GET and POST. + + """ + try: + self.close_connection = 1 + self.wfile.flush() + + self.connection.settimeout(5) + + self.raw_requestline = self.rfile.readline(65537) + + self.connection.settimeout(90) + + if len(self.raw_requestline) > 65536: + log.error("Read request too long") + self.requestline = '' + self.request_version = '' + self.command = '' + self.send_error(414) + return + if not self.raw_requestline: + log.error("Connection closed") + return + log.debug("First line '%s'", self.raw_requestline) + if not self.parse_request(): + # An error code has been sent, just exit + self.close_connection = 1 + return + # parse_request clears close_connection on all http/1.1 links + # it should only do this if a keep-alive header is seen + self.close_connection = 1 + conntype = self.headers.get('Connection', "") + if (conntype.lower() == 'keep-alive' + and self.protocol_version >= "HTTP/1.1"): + log.debug("keep-alive") + self.close_connection = 0 + reqlen = self.headers.get('Content-Length', "0") + log.debug("reqlen %s", reqlen) + self.xml_request = self.rfile.read(int(reqlen)) + environ = { + 'REQUEST_METHOD': self.command, + 'CONTENT_LENGTH': reqlen, + 'PATH_INFO': self.path, + 'wsgi.input': StringIO(self.xml_request) + } + for name, value in self.headers.items(): + environ['HTTP_' + name.replace('-', '_').upper()] = value + def start_response(status, headers): + (code, message) = status.split(' ', 1) + self.send_response(int(code), message) + self.send_connection_header() + for header, value in headers: + self.send_header(header, value) + self.end_headers() + lines = self.app(environ, start_response) + if lines is not None: + self.wfile.writelines(lines) + self.wfile.flush() #actually send the response if not already done. + except socket.timeout as e: + #a read or a write timed out. Discard this connection + log.error("Request timed out: %r", e) + self.close_connection = 1 + return + except ssl.SSLError, x: + #an io error. Discard this connection + log.error("SSL request error: %r", x.args[0]) + self.close_connection = 1 + return diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..eeb71b5 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,2 @@ +from calypso import CalypsoApp +application = CalypsoApp()
_______________________________________________ Calypso mailing list [email protected] http://keithp.com/mailman/listinfo/calypso
