diff --git a/radicale/__init__.py b/radicale/__init__.py index c0e9cba..d9b4500 100644 --- a/radicale/__init__.py +++ b/radicale/__init__.py @@ -17,17 +17,13 @@ # along with Radicale. If not, see . """ -Radicale Server module. +Radicale WSGI application. -This module offers a WSGI application class. - -To use this module, you should take a look at the file ``radicale.py`` that -should have been included in this package. +Can be used with an external WSGI server or the built-in server. """ import base64 -import contextlib import datetime import io import itertools @@ -38,15 +34,11 @@ import posixpath import pprint import random import socket -import socketserver -import ssl -import sys import threading import time -import wsgiref.simple_server import zlib from http import client -from urllib.parse import unquote, urlparse +from urllib.parse import urlparse from xml.etree import ElementTree as ET import vobject @@ -98,162 +90,6 @@ INTERNAL_SERVER_ERROR = ( DAV_HEADERS = "1, 2, 3, calendar-access, addressbook, extended-mkcol" -class HTTPServer(wsgiref.simple_server.WSGIServer): - """HTTP server.""" - - # These class attributes must be set before creating instance - client_timeout = None - max_connections = None - - def __init__(self, address, handler, bind_and_activate=True): - """Create server.""" - ipv6 = ":" in address[0] - - if ipv6: - self.address_family = socket.AF_INET6 - - # Do not bind and activate, as we might change socket options - super().__init__(address, handler, False) - - if ipv6: - # Only allow IPv6 connections to the IPv6 socket - self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) - - if self.max_connections: - self.connections_guard = threading.BoundedSemaphore( - self.max_connections) - else: - # use dummy context manager - self.connections_guard = contextlib.ExitStack() - - if bind_and_activate: - try: - self.server_bind() - self.server_activate() - except BaseException: - self.server_close() - raise - - if self.client_timeout and sys.version_info < (3, 5, 2): - logger.warning("Using server.timeout with Python < 3.5.2 " - "can cause network connection failures") - - def get_request(self): - # Set timeout for client - _socket, address = super().get_request() - if self.client_timeout: - _socket.settimeout(self.client_timeout) - return _socket, address - - def handle_error(self, request, client_address): - if issubclass(sys.exc_info()[0], socket.timeout): - logger.info("client timed out", exc_info=True) - else: - logger.error("An exception occurred during request: %s", - sys.exc_info()[1], exc_info=True) - - -class HTTPSServer(HTTPServer): - """HTTPS server.""" - - # These class attributes must be set before creating instance - certificate = None - key = None - protocol = None - ciphers = None - certificate_authority = None - - def __init__(self, address, handler): - """Create server by wrapping HTTP socket in an SSL socket.""" - super().__init__(address, handler, bind_and_activate=False) - - self.socket = ssl.wrap_socket( - self.socket, self.key, self.certificate, server_side=True, - cert_reqs=ssl.CERT_REQUIRED if self.certificate_authority else - ssl.CERT_NONE, - ca_certs=self.certificate_authority or None, - ssl_version=self.protocol, ciphers=self.ciphers, - do_handshake_on_connect=False) - - self.server_bind() - self.server_activate() - - -class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer): - def process_request_thread(self, request, client_address): - with self.connections_guard: - return super().process_request_thread(request, client_address) - - -class ThreadedHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): - def process_request_thread(self, request, client_address): - try: - try: - request.do_handshake() - except socket.timeout: - raise - except Exception as e: - raise RuntimeError("SSL handshake failed: %s" % e) from e - except Exception: - try: - self.handle_error(request, client_address) - finally: - self.shutdown_request(request) - return - with self.connections_guard: - return super().process_request_thread(request, client_address) - - -class ServerHandler(wsgiref.simple_server.ServerHandler): - - # Don't pollute WSGI environ with OS environment - os_environ = {} - - def log_exception(self, exc_info): - logger.error("An exception occurred during request: %s", - exc_info[1], exc_info=exc_info) - - -class RequestHandler(wsgiref.simple_server.WSGIRequestHandler): - """HTTP requests handler.""" - - def log_request(self, code="-", size="-"): - """Disable request logging.""" - - def log_error(self, format, *args): - msg = format % args - logger.error("An error occurred during request: %s" % msg) - - def get_environ(self): - env = super().get_environ() - if hasattr(self.connection, "getpeercert"): - # The certificate can be evaluated by the auth module - env["REMOTE_CERTIFICATE"] = self.connection.getpeercert() - # Parent class only tries latin1 encoding - env["PATH_INFO"] = unquote(self.path.split("?", 1)[0]) - return env - - def handle(self): - """Copy of WSGIRequestHandler.handle with different ServerHandler""" - - self.raw_requestline = self.rfile.readline(65537) - if len(self.raw_requestline) > 65536: - self.requestline = '' - self.request_version = '' - self.command = '' - self.send_error(414) - return - - if not self.parse_request(): - return - - handler = ServerHandler( - self.rfile, self.wfile, self.get_stderr(), self.get_environ() - ) - handler.request_handler = self - handler.run(self.server.get_app()) - - class Application: """WSGI application managing collections.""" diff --git a/radicale/__main__.py b/radicale/__main__.py index 7b5bf54..ba7ee71 100644 --- a/radicale/__main__.py +++ b/radicale/__main__.py @@ -17,22 +17,16 @@ """ Radicale executable module. -This module can be executed from a command line with ``$python -m radicale`` or -from a python programme with ``radicale.__main__.run()``. +This module can be executed from a command line with ``$python -m radicale``. """ import argparse import os -import select -import signal -import socket -import ssl -from wsgiref.simple_server import make_server -from radicale import (VERSION, Application, RequestHandler, ThreadedHTTPServer, - ThreadedHTTPSServer, config, log, storage) +from radicale import VERSION, config, log, storage from radicale.log import logger +from radicale.server import serve def run(): @@ -133,107 +127,5 @@ def run(): exit(1) -def serve(configuration): - """Serve radicale from configuration.""" - logger.info("Starting Radicale") - - # Create collection servers - servers = {} - if configuration.getboolean("server", "ssl"): - server_class = ThreadedHTTPSServer - server_class.certificate = configuration.get("server", "certificate") - server_class.key = configuration.get("server", "key") - server_class.certificate_authority = configuration.get( - "server", "certificate_authority") - server_class.ciphers = configuration.get("server", "ciphers") - server_class.protocol = getattr( - ssl, configuration.get("server", "protocol"), ssl.PROTOCOL_SSLv23) - # Test if the SSL files can be read - for name in ["certificate", "key"] + ( - ["certificate_authority"] - if server_class.certificate_authority else []): - filename = getattr(server_class, name) - try: - open(filename, "r").close() - except OSError as e: - raise RuntimeError("Failed to read SSL %s %r: %s" % - (name, filename, e)) from e - else: - server_class = ThreadedHTTPServer - server_class.client_timeout = configuration.getint("server", "timeout") - server_class.max_connections = configuration.getint( - "server", "max_connections") - - if not configuration.getboolean("server", "dns_lookup"): - RequestHandler.address_string = lambda self: self.client_address[0] - - shutdown_program = False - - for host in configuration.get("server", "hosts").split(","): - try: - address, port = host.strip().rsplit(":", 1) - address, port = address.strip("[] "), int(port) - except ValueError as e: - raise RuntimeError( - "Failed to parse address %r: %s" % (host, e)) from e - application = Application(configuration) - try: - server = make_server( - address, port, application, server_class, RequestHandler) - except OSError as e: - raise RuntimeError( - "Failed to start server %r: %s" % (host, e)) from e - servers[server.socket] = server - logger.info("Listening to %r on port %d%s", - server.server_name, server.server_port, " using SSL" - if configuration.getboolean("server", "ssl") else "") - - # Create a socket pair to notify the select syscall of program shutdown - # This is not available in python < 3.5 on Windows - if hasattr(socket, "socketpair"): - shutdown_program_socket_in, shutdown_program_socket_out = ( - socket.socketpair()) - else: - shutdown_program_socket_in, shutdown_program_socket_out = None, None - - # SIGTERM and SIGINT (aka KeyboardInterrupt) should just mark this for - # shutdown - def shutdown(*args): - nonlocal shutdown_program - if shutdown_program: - # Ignore following signals - return - logger.info("Stopping Radicale") - shutdown_program = True - if shutdown_program_socket_in: - shutdown_program_socket_in.sendall(b"goodbye") - signal.signal(signal.SIGTERM, shutdown) - signal.signal(signal.SIGINT, shutdown) - - # Main loop: wait for requests on any of the servers or program shutdown - sockets = list(servers.keys()) - if shutdown_program_socket_out: - # Use socket pair to get notified of program shutdown - sockets.append(shutdown_program_socket_out) - select_timeout = None - if not shutdown_program_socket_out or os.name == "nt": - # Fallback to busy waiting. (select.select blocks SIGINT on Windows.) - select_timeout = 1.0 - logger.info("Radicale server ready") - while not shutdown_program: - try: - rlist, _, xlist = select.select( - sockets, [], sockets, select_timeout) - except (KeyboardInterrupt, select.error): - # SIGINT is handled by signal handler above - rlist, xlist = [], [] - if xlist: - raise RuntimeError("unhandled socket error") - if rlist: - server = servers.get(rlist[0]) - if server: - server.handle_request() - - if __name__ == "__main__": run() diff --git a/radicale/server.py b/radicale/server.py new file mode 100644 index 0000000..4c43964 --- /dev/null +++ b/radicale/server.py @@ -0,0 +1,295 @@ +# This file is part of Radicale Server - Calendar Server +# Copyright © 2008 Nicolas Kandel +# Copyright © 2008 Pascal Halter +# Copyright © 2008-2017 Guillaume Ayoub +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Radicale WSGI server. + +""" + +import contextlib +import os +import select +import signal +import socket +import socketserver +import ssl +import sys +import threading +import wsgiref.simple_server +from urllib.parse import unquote + +from radicale import Application +from radicale.log import logger + + +class HTTPServer(wsgiref.simple_server.WSGIServer): + """HTTP server.""" + + # These class attributes must be set before creating instance + client_timeout = None + max_connections = None + + def __init__(self, address, handler, bind_and_activate=True): + """Create server.""" + ipv6 = ":" in address[0] + + if ipv6: + self.address_family = socket.AF_INET6 + + # Do not bind and activate, as we might change socket options + super().__init__(address, handler, False) + + if ipv6: + # Only allow IPv6 connections to the IPv6 socket + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + + if self.max_connections: + self.connections_guard = threading.BoundedSemaphore( + self.max_connections) + else: + # use dummy context manager + self.connections_guard = contextlib.ExitStack() + + if bind_and_activate: + try: + self.server_bind() + self.server_activate() + except BaseException: + self.server_close() + raise + + if self.client_timeout and sys.version_info < (3, 5, 2): + logger.warning("Using server.timeout with Python < 3.5.2 " + "can cause network connection failures") + + def get_request(self): + # Set timeout for client + _socket, address = super().get_request() + if self.client_timeout: + _socket.settimeout(self.client_timeout) + return _socket, address + + def handle_error(self, request, client_address): + if issubclass(sys.exc_info()[0], socket.timeout): + logger.info("client timed out", exc_info=True) + else: + logger.error("An exception occurred during request: %s", + sys.exc_info()[1], exc_info=True) + + +class HTTPSServer(HTTPServer): + """HTTPS server.""" + + # These class attributes must be set before creating instance + certificate = None + key = None + protocol = None + ciphers = None + certificate_authority = None + + def __init__(self, address, handler): + """Create server by wrapping HTTP socket in an SSL socket.""" + super().__init__(address, handler, bind_and_activate=False) + + self.socket = ssl.wrap_socket( + self.socket, self.key, self.certificate, server_side=True, + cert_reqs=ssl.CERT_REQUIRED if self.certificate_authority else + ssl.CERT_NONE, + ca_certs=self.certificate_authority or None, + ssl_version=self.protocol, ciphers=self.ciphers, + do_handshake_on_connect=False) + + self.server_bind() + self.server_activate() + + +class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer): + def process_request_thread(self, request, client_address): + with self.connections_guard: + return super().process_request_thread(request, client_address) + + +class ThreadedHTTPSServer(socketserver.ThreadingMixIn, HTTPSServer): + def process_request_thread(self, request, client_address): + try: + try: + request.do_handshake() + except socket.timeout: + raise + except Exception as e: + raise RuntimeError("SSL handshake failed: %s" % e) from e + except Exception: + try: + self.handle_error(request, client_address) + finally: + self.shutdown_request(request) + return + with self.connections_guard: + return super().process_request_thread(request, client_address) + + +class ServerHandler(wsgiref.simple_server.ServerHandler): + + # Don't pollute WSGI environ with OS environment + os_environ = {} + + def log_exception(self, exc_info): + logger.error("An exception occurred during request: %s", + exc_info[1], exc_info=exc_info) + + +class RequestHandler(wsgiref.simple_server.WSGIRequestHandler): + """HTTP requests handler.""" + + def log_request(self, code="-", size="-"): + """Disable request logging.""" + + def log_error(self, format, *args): + msg = format % args + logger.error("An error occurred during request: %s" % msg) + + def get_environ(self): + env = super().get_environ() + if hasattr(self.connection, "getpeercert"): + # The certificate can be evaluated by the auth module + env["REMOTE_CERTIFICATE"] = self.connection.getpeercert() + # Parent class only tries latin1 encoding + env["PATH_INFO"] = unquote(self.path.split("?", 1)[0]) + return env + + def handle(self): + """Copy of WSGIRequestHandler.handle with different ServerHandler""" + + self.raw_requestline = self.rfile.readline(65537) + if len(self.raw_requestline) > 65536: + self.requestline = '' + self.request_version = '' + self.command = '' + self.send_error(414) + return + + if not self.parse_request(): + return + + handler = ServerHandler( + self.rfile, self.wfile, self.get_stderr(), self.get_environ() + ) + handler.request_handler = self + handler.run(self.server.get_app()) + + +def serve(configuration): + """Serve radicale from configuration.""" + logger.info("Starting Radicale") + + # Create collection servers + servers = {} + if configuration.getboolean("server", "ssl"): + server_class = ThreadedHTTPSServer + server_class.certificate = configuration.get("server", "certificate") + server_class.key = configuration.get("server", "key") + server_class.certificate_authority = configuration.get( + "server", "certificate_authority") + server_class.ciphers = configuration.get("server", "ciphers") + server_class.protocol = getattr( + ssl, configuration.get("server", "protocol"), ssl.PROTOCOL_SSLv23) + # Test if the SSL files can be read + for name in ["certificate", "key"] + ( + ["certificate_authority"] + if server_class.certificate_authority else []): + filename = getattr(server_class, name) + try: + open(filename, "r").close() + except OSError as e: + raise RuntimeError("Failed to read SSL %s %r: %s" % + (name, filename, e)) from e + else: + server_class = ThreadedHTTPServer + server_class.client_timeout = configuration.getint("server", "timeout") + server_class.max_connections = configuration.getint( + "server", "max_connections") + + if not configuration.getboolean("server", "dns_lookup"): + RequestHandler.address_string = lambda self: self.client_address[0] + + shutdown_program = False + + for host in configuration.get("server", "hosts").split(","): + try: + address, port = host.strip().rsplit(":", 1) + address, port = address.strip("[] "), int(port) + except ValueError as e: + raise RuntimeError( + "Failed to parse address %r: %s" % (host, e)) from e + application = Application(configuration) + try: + server = wsgiref.simple_server.make_server( + address, port, application, server_class, RequestHandler) + except OSError as e: + raise RuntimeError( + "Failed to start server %r: %s" % (host, e)) from e + servers[server.socket] = server + logger.info("Listening to %r on port %d%s", + server.server_name, server.server_port, " using SSL" + if configuration.getboolean("server", "ssl") else "") + + # Create a socket pair to notify the select syscall of program shutdown + # This is not available in python < 3.5 on Windows + if hasattr(socket, "socketpair"): + shutdown_program_socket_in, shutdown_program_socket_out = ( + socket.socketpair()) + else: + shutdown_program_socket_in, shutdown_program_socket_out = None, None + + # SIGTERM and SIGINT (aka KeyboardInterrupt) should just mark this for + # shutdown + def shutdown(*args): + nonlocal shutdown_program + if shutdown_program: + # Ignore following signals + return + logger.info("Stopping Radicale") + shutdown_program = True + if shutdown_program_socket_in: + shutdown_program_socket_in.sendall(b"goodbye") + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + # Main loop: wait for requests on any of the servers or program shutdown + sockets = list(servers.keys()) + if shutdown_program_socket_out: + # Use socket pair to get notified of program shutdown + sockets.append(shutdown_program_socket_out) + select_timeout = None + if not shutdown_program_socket_out or os.name == "nt": + # Fallback to busy waiting. (select.select blocks SIGINT on Windows.) + select_timeout = 1.0 + logger.info("Radicale server ready") + while not shutdown_program: + try: + rlist, _, xlist = select.select( + sockets, [], sockets, select_timeout) + except (KeyboardInterrupt, select.error): + # SIGINT is handled by signal handler above + rlist, xlist = [], [] + if xlist: + raise RuntimeError("unhandled socket error") + if rlist: + server = servers.get(rlist[0]) + if server: + server.handle_request()