• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Kozea / Radicale / 13614170188

02 Mar 2025 09:35AM UTC coverage: 74.144% (-0.3%) from 74.397%
13614170188

push

github

web-flow
Merge pull request #1720 from pbiering/improvements-2

Adjustments related to reverse proxy

1811 of 2601 branches covered (69.63%)

Branch coverage included in aggregate %.

30 of 51 new or added lines in 3 files covered. (58.82%)

4 existing lines in 3 files now uncovered.

4254 of 5579 relevant lines covered (76.25%)

12.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.75
/radicale/server.py
1
# This file is part of Radicale - CalDAV and CardDAV server
2
# Copyright © 2008 Nicolas Kandel
3
# Copyright © 2008 Pascal Halter
4
# Copyright © 2008-2017 Guillaume Ayoub
5
# Copyright © 2017-2023 Unrud <unrud@outlook.com>
6
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
7
#
8
# This library is free software: you can redistribute it and/or modify
9
# it under the terms of the GNU General Public License as published by
10
# the Free Software Foundation, either version 3 of the License, or
11
# (at your option) any later version.
12
#
13
# This library is distributed in the hope that it will be useful,
14
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
# GNU General Public License for more details.
17
#
18
# You should have received a copy of the GNU General Public License
19
# along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
20

21
"""
3✔
22
Built-in WSGI server.
23

24
"""
25

26
import http
17✔
27
import select
17✔
28
import socket
17✔
29
import socketserver
17✔
30
import ssl
17✔
31
import sys
17✔
32
import wsgiref.simple_server
17✔
33
from typing import (Any, Callable, Dict, List, MutableMapping, Optional, Set,
17✔
34
                    Tuple, Union)
35
from urllib.parse import unquote
17✔
36

37
from radicale import Application, config, utils
17✔
38
from radicale.log import logger
17✔
39

40
COMPAT_EAI_ADDRFAMILY: int
17✔
41
if hasattr(socket, "EAI_ADDRFAMILY"):
17✔
42
    COMPAT_EAI_ADDRFAMILY = socket.EAI_ADDRFAMILY  # type:ignore[attr-defined]
12✔
43
elif hasattr(socket, "EAI_NONAME"):
5!
44
    # Windows and BSD don't have a special error code for this
45
    COMPAT_EAI_ADDRFAMILY = socket.EAI_NONAME
5✔
46
COMPAT_EAI_NODATA: int
17✔
47
if hasattr(socket, "EAI_NODATA"):
17!
48
    COMPAT_EAI_NODATA = socket.EAI_NODATA
17✔
49
elif hasattr(socket, "EAI_NONAME"):
×
50
    # Windows and BSD don't have a special error code for this
51
    COMPAT_EAI_NODATA = socket.EAI_NONAME
×
52
COMPAT_IPPROTO_IPV6: int
17✔
53
if hasattr(socket, "IPPROTO_IPV6"):
17!
54
    COMPAT_IPPROTO_IPV6 = socket.IPPROTO_IPV6
17✔
55
elif sys.platform == "win32":
×
56
    # HACK: https://bugs.python.org/issue29515
57
    COMPAT_IPPROTO_IPV6 = 41
×
58

59

60
# IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
61
ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
17✔
62
                     Tuple[str, int, int, int]]
63

64

65
def format_address(address: ADDRESS_TYPE) -> str:
17✔
66
    host, port, *_ = address
17✔
67
    if not isinstance(host, str):
17✔
68
        raise NotImplementedError("Unsupported address format: %r" %
69
                                  (address,))
70
    if host.find(":") == -1:
17✔
71
        return "%s:%d" % (host, port)
17✔
72
    else:
73
        return "[%s]:%d" % (host, port)
17✔
74

75

76
class ParallelHTTPServer(socketserver.ThreadingMixIn,
17✔
77
                         wsgiref.simple_server.WSGIServer):
78

79
    configuration: config.Configuration
17✔
80
    worker_sockets: Set[socket.socket]
17✔
81
    _timeout: float
17✔
82

83
    # We wait for child threads ourself (ThreadingMixIn)
84
    block_on_close: bool = False
17✔
85
    daemon_threads: bool = True
17✔
86

87
    def __init__(self, configuration: config.Configuration, family: int,
17✔
88
                 address: Tuple[str, int], RequestHandlerClass:
89
                 Callable[..., http.server.BaseHTTPRequestHandler]) -> None:
90
        self.configuration = configuration
17✔
91
        self.address_family = family
17✔
92
        super().__init__(address, RequestHandlerClass)
17✔
93
        self.worker_sockets = set()
17✔
94
        self._timeout = configuration.get("server", "timeout")
17✔
95

96
    def server_bind(self) -> None:
17✔
97
        if self.address_family == socket.AF_INET6:
17✔
98
            # Only allow IPv6 connections to the IPv6 socket
99
            self.socket.setsockopt(COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
17✔
100
        super().server_bind()
17✔
101

102
    def get_request(  # type:ignore[override]
17✔
103
            self) -> Tuple[socket.socket, Tuple[ADDRESS_TYPE, socket.socket]]:
104
        # Set timeout for client
105
        request: socket.socket
106
        client_address: ADDRESS_TYPE
107
        request, client_address = super().get_request()  # type:ignore[misc]
17✔
108
        if self._timeout > 0:
17!
109
            request.settimeout(self._timeout)
17✔
110
        worker_socket, worker_socket_out = socket.socketpair()
17✔
111
        self.worker_sockets.add(worker_socket_out)
17✔
112
        # HACK: Forward `worker_socket` via `client_address` return value
113
        # to worker thread.
114
        # The super class calls `verify_request`, `process_request` and
115
        # `handle_error` with modified `client_address` value.
116
        return request, (client_address, worker_socket)
17✔
117

118
    def verify_request(  # type:ignore[override]
17✔
119
            self, request: socket.socket, client_address_and_socket:
120
            Tuple[ADDRESS_TYPE, socket.socket]) -> bool:
121
        return True
17✔
122

123
    def process_request(  # type:ignore[override]
17✔
124
            self, request: socket.socket, client_address_and_socket:
125
            Tuple[ADDRESS_TYPE, socket.socket]) -> None:
126
        # HACK: Super class calls `finish_request` in new thread with
127
        # `client_address_and_socket`
128
        return super().process_request(
17✔
129
            request, client_address_and_socket)  # type:ignore[arg-type]
130

131
    def finish_request(  # type:ignore[override]
17✔
132
            self, request: socket.socket, client_address_and_socket:
133
            Tuple[ADDRESS_TYPE, socket.socket]) -> None:
134
        # HACK: Unpack `client_address_and_socket` and call super class
135
        # `finish_request` with original `client_address`
136
        client_address, worker_socket = client_address_and_socket
17✔
137
        try:
17✔
138
            return self.finish_request_locked(request, client_address)
17✔
139
        finally:
140
            worker_socket.close()
17✔
141

142
    def finish_request_locked(self, request: socket.socket,
17✔
143
                              client_address: ADDRESS_TYPE) -> None:
144
        return super().finish_request(
17✔
145
            request, client_address)  # type:ignore[arg-type]
146

147
    def handle_error(  # type:ignore[override]
17✔
148
            self, request: socket.socket,
149
            client_address_or_client_address_and_socket:
150
            Union[ADDRESS_TYPE, Tuple[ADDRESS_TYPE, socket.socket]]) -> None:
151
        # HACK: This method can be called with the modified
152
        # `client_address_and_socket` or the original `client_address` value
153
        e = sys.exc_info()[1]
×
154
        assert e is not None
×
155
        if isinstance(e, socket.timeout):
×
156
            logger.info("Client timed out", exc_info=True)
×
157
        else:
158
            logger.error("An exception occurred during request: %s",
×
159
                         sys.exc_info()[1], exc_info=True)
160

161

162
class ParallelHTTPSServer(ParallelHTTPServer):
17✔
163

164
    def server_bind(self) -> None:
17✔
165
        super().server_bind()
17✔
166
        # Wrap the TCP socket in an SSL socket
167
        certfile: str = self.configuration.get("server", "certificate")
17✔
168
        keyfile: str = self.configuration.get("server", "key")
17✔
169
        cafile: str = self.configuration.get("server", "certificate_authority")
17✔
170
        protocol: str = self.configuration.get("server", "protocol")
17✔
171
        ciphersuite: str = self.configuration.get("server", "ciphersuite")
17✔
172
        # Test if the files can be read
173
        for name, filename in [("certificate", certfile), ("key", keyfile),
17✔
174
                               ("certificate_authority", cafile)]:
175
            type_name = config.DEFAULT_CONFIG_SCHEMA["server"][name][
17✔
176
                "type"].__name__
177
            source = self.configuration.get_source("server", name)
17✔
178
            if name == "certificate_authority" and not filename:
17✔
179
                continue
17✔
180
            try:
17✔
181
                open(filename).close()
17✔
182
            except OSError as e:
×
183
                raise RuntimeError(
×
184
                    "Invalid %s value for option %r in section %r in %s: %r "
185
                    "(%s)" % (type_name, name, "server", source, filename,
186
                              e)) from e
187
        context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
17✔
188
        logger.info("SSL load files certificate='%s' key='%s'", certfile, keyfile)
17✔
189
        context.load_cert_chain(certfile=certfile, keyfile=keyfile)
17✔
190
        if protocol:
17!
191
            logger.info("SSL set explicit protocols (maybe not all supported by underlying OpenSSL): '%s'", protocol)
×
192
            context.options = utils.ssl_context_options_by_protocol(protocol, context.options)
×
193
            context.minimum_version = utils.ssl_context_minimum_version_by_options(context.options)
×
194
            if (context.minimum_version == 0):
×
195
                raise RuntimeError("No SSL minimum protocol active")
×
196
            context.maximum_version = utils.ssl_context_maximum_version_by_options(context.options)
×
197
            if (context.maximum_version == 0):
×
198
                raise RuntimeError("No SSL maximum protocol active")
×
199
        else:
200
            logger.info("SSL active protocols: (system-default)")
17✔
201
        logger.debug("SSL minimum acceptable protocol: %s", context.minimum_version)
17✔
202
        logger.debug("SSL maximum acceptable protocol: %s", context.maximum_version)
17✔
203
        logger.info("SSL accepted protocols: %s", ' '.join(utils.ssl_get_protocols(context)))
17✔
204
        if ciphersuite:
17!
205
            logger.info("SSL set explicit ciphersuite (maybe not all supported by underlying OpenSSL): '%s'", ciphersuite)
×
206
            context.set_ciphers(ciphersuite)
×
207
        else:
208
            logger.info("SSL active ciphersuite: (system-default)")
17✔
209
        cipherlist = []
17✔
210
        for entry in context.get_ciphers():
17✔
211
            cipherlist.append(entry["name"])
17✔
212
        logger.info("SSL accepted ciphers: %s", ' '.join(cipherlist))
17✔
213
        if cafile:
17!
214
            logger.info("SSL enable mandatory client certificate verification using CA file='%s'", cafile)
×
215
            context.load_verify_locations(cafile=cafile)
×
216
            context.verify_mode = ssl.CERT_REQUIRED
×
217
        self.socket = context.wrap_socket(
17✔
218
            self.socket, server_side=True, do_handshake_on_connect=False)
219

220
    def finish_request_locked(  # type:ignore[override]
17✔
221
            self, request: ssl.SSLSocket, client_address: ADDRESS_TYPE
222
            ) -> None:
223
        try:
17✔
224
            try:
17✔
225
                request.do_handshake()
17✔
226
            except socket.timeout:
×
227
                raise
×
228
            except Exception as e:
×
229
                raise RuntimeError("SSL handshake failed: %s" % e) from e
×
230
        except Exception:
×
231
            try:
×
232
                self.handle_error(request, client_address)
×
233
            finally:
234
                self.shutdown_request(request)  # type:ignore[attr-defined]
×
235
            return
×
236
        return super().finish_request_locked(request, client_address)
17✔
237

238

239
class ServerHandler(wsgiref.simple_server.ServerHandler):
17✔
240

241
    # Don't pollute WSGI environ with OS environment
242
    os_environ: MutableMapping[str, str] = {}
17✔
243

244
    def log_exception(self, exc_info) -> None:
17✔
245
        logger.error("An exception occurred during request: %s",
×
246
                     exc_info[1], exc_info=exc_info)  # type:ignore[arg-type]
247

248

249
class RequestHandler(wsgiref.simple_server.WSGIRequestHandler):
17✔
250
    """HTTP requests handler."""
251

252
    # HACK: Assigned in `socketserver.StreamRequestHandler`
253
    connection: socket.socket
17✔
254

255
    def log_request(self, code: Union[int, str] = "-",
17✔
256
                    size: Union[int, str] = "-") -> None:
257
        pass  # Disable request logging.
17✔
258

259
    def log_error(self, format_: str, *args: Any) -> None:
17✔
260
        logger.error("An error occurred during request: %s", format_ % args)
×
261

262
    def get_environ(self) -> Dict[str, Any]:
17✔
263
        env = super().get_environ()
17✔
264
        if isinstance(self.connection, ssl.SSLSocket):
17✔
265
            # The certificate can be evaluated by the auth module
266
            env["REMOTE_CERTIFICATE"] = self.connection.getpeercert()
17✔
267
        # Parent class only tries latin1 encoding
268
        env["PATH_INFO"] = unquote(self.path.split("?", 1)[0])
17✔
269
        return env
17✔
270

271
    def handle(self) -> None:
17✔
272
        """Copy of WSGIRequestHandler.handle with different ServerHandler"""
273

274
        self.raw_requestline = self.rfile.readline(65537)
17✔
275
        if len(self.raw_requestline) > 65536:
17!
276
            self.requestline = ""
×
277
            self.request_version = ""
×
278
            self.command = ""
×
279
            self.send_error(414)
×
280
            return
×
281

282
        if not self.parse_request():
17!
283
            return
×
284

285
        handler = ServerHandler(
17✔
286
            self.rfile, self.wfile, self.get_stderr(), self.get_environ()
287
        )
288
        handler.request_handler = self  # type:ignore[attr-defined]
17✔
289
        app = self.server.get_app()  # type:ignore[attr-defined]
17✔
290
        handler.run(app)
17✔
291

292

293
def serve(configuration: config.Configuration,
17✔
294
          shutdown_socket: Optional[socket.socket] = None) -> None:
295
    """Serve radicale from configuration.
296

297
    `shutdown_socket` can be used to gracefully shutdown the server.
298
    The socket can be created with `socket.socketpair()`, when the other socket
299
    gets closed the server stops accepting new requests by clients and the
300
    function returns after all active requests are finished.
301

302
    """
303

304
    logger.info("Starting Radicale (%s)", utils.packages_version())
17✔
305
    # Copy configuration before modifying
306
    configuration = configuration.copy()
17✔
307
    configuration.update({"server": {"_internal_server": "True"}}, "server",
17✔
308
                         privileged=True)
309

310
    use_ssl: bool = configuration.get("server", "ssl")
17✔
311
    server_class = ParallelHTTPSServer if use_ssl else ParallelHTTPServer
17✔
312
    application = Application(configuration)
17✔
313
    servers = {}
17✔
314
    try:
17✔
315
        hosts: List[Tuple[str, int]] = configuration.get("server", "hosts")
17✔
316
        for address_port in hosts:
17✔
317
            # retrieve IPv4/IPv6 address of address
318
            try:
17✔
319
                getaddrinfo = socket.getaddrinfo(address_port[0], address_port[1], 0, socket.SOCK_STREAM, socket.IPPROTO_TCP)
17✔
320
            except OSError as e:
×
321
                logger.warning("cannot retrieve IPv4 or IPv6 address of '%s': %s" % (format_address(address_port), e))
×
322
                continue
×
323
            logger.debug("getaddrinfo of '%s': %s" % (format_address(address_port), getaddrinfo))
17✔
324
            for (address_family, socket_kind, socket_proto, socket_flags, socket_address) in getaddrinfo:
17✔
325
                logger.debug("try to create server socket on '%s'" % (format_address(socket_address)))
17✔
326
                try:
17✔
327
                    server = server_class(configuration, address_family, (socket_address[0], socket_address[1]), RequestHandler)
17✔
328
                except OSError as e:
×
329
                    logger.warning("cannot create server socket on '%s': %s" % (format_address(socket_address), e))
×
330
                    continue
×
331
                servers[server.socket] = server
17✔
332
                server.set_app(application)
17✔
333
                logger.info("Listening on %r%s",
17✔
334
                            format_address(server.server_address),
335
                            " with SSL" if use_ssl else "")
336
        if not servers:
17!
337
            raise RuntimeError("No servers started")
×
338

339
        # Mainloop
340
        select_timeout = None
17✔
341
        if sys.platform == "win32":
17✔
342
            # Fallback to busy waiting. (select(...) blocks SIGINT on Windows.)
343
            select_timeout = 1.0
5✔
344
        max_connections: int = configuration.get("server", "max_connections")
17✔
345
        logger.info("Radicale server ready")
17✔
346
        while True:
12✔
347
            rlist: List[socket.socket] = []
17✔
348
            # Wait for finished clients
349
            for server in servers.values():
17✔
350
                rlist.extend(server.worker_sockets)
17✔
351
            # Accept new connections if max_connections is not reached
352
            if max_connections <= 0 or len(rlist) < max_connections:
17!
353
                rlist.extend(servers)
17✔
354
            # Use socket to get notified of program shutdown
355
            if shutdown_socket is not None:
17!
356
                rlist.append(shutdown_socket)
17✔
357
            rlist, _, _ = select.select(rlist, [], [], select_timeout)
17✔
358
            rset = set(rlist)
17✔
359
            if shutdown_socket in rset:
17✔
360
                logger.info("Stopping Radicale")
17✔
361
                break
17✔
362
            for server in servers.values():
17✔
363
                finished_sockets = server.worker_sockets.intersection(rset)
17✔
364
                for s in finished_sockets:
17✔
365
                    s.close()
17✔
366
                    server.worker_sockets.remove(s)
17✔
367
                    rset.remove(s)
17✔
368
                if finished_sockets:
17✔
369
                    server.service_actions()
17✔
370
            if rset:
17✔
371
                active_server = servers.get(rset.pop())
17✔
372
                if active_server:
17!
373
                    active_server.handle_request()
17✔
374
    finally:
375
        # Wait for clients to finish and close servers
376
        for server in servers.values():
17✔
377
            for s in server.worker_sockets:
17!
UNCOV
378
                s.recv(1)
×
UNCOV
379
                s.close()
×
380
            server.server_close()
17✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc