diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d8687c..1ce89ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 3.4.2.dev +## 3.5.0.dev * Add: option [auth] type oauth2 by code migration from https://gitlab.mim-libre.fr/alphabet/radicale_oauth/-/blob/dev/oauth2/ * Fix: catch OS errors on PUT MKCOL MKCALENDAR MOVE PROPPATCH (insufficient storage, access denied, internal server error) @@ -9,6 +9,9 @@ * Add: option [auth] type pam by code migration from v1, add new option pam_serivce * Cosmetics: extend list of used modules with their version on startup * Improve: WebUI +* Add: option [server] script_name for reverse proxy base_prefix handling +* Fix: proper base_prefix stripping if running behind reverse proxy +* Review: Apache reverse proxy config example ## 3.4.1 * Add: option [auth] dovecot_connection_type / dovecot_host / dovecot_port diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b99b1d61..f4e04d8f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -775,6 +775,12 @@ Format: OpenSSL cipher list (see also "man openssl-ciphers") Default: (system-default) +##### script_name + +Strip script name from URI if called by reverse proxy + +Default: (taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME) + #### encoding ##### request diff --git a/config b/config index e367083c..14bb3c40 100644 --- a/config +++ b/config @@ -46,6 +46,9 @@ # SSL ciphersuite, secure configuration: DHE:ECDHE:-NULL:-SHA (see also "man openssl-ciphers") #ciphersuite = (default) +# script name to strip from URI if called by reverse proxy +#script_name = (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME) + [encoding] diff --git a/contrib/apache/radicale.conf b/contrib/apache/radicale.conf index 102dc794..d92c5c31 100644 --- a/contrib/apache/radicale.conf +++ b/contrib/apache/radicale.conf @@ -4,6 +4,7 @@ ## Apache acting as reverse proxy and forward requests via ProxyPass to a running "radicale" server # SELinux WARNING: To use this correctly, you will need to set: # setsebool -P httpd_can_network_connect=1 +# URI prefix: /radicale #Define RADICALE_SERVER_REVERSE_PROXY @@ -11,11 +12,12 @@ # MAY CONFLICT with other WSG servers on same system -> use then inside a VirtualHost # SELinux WARNING: To use this correctly, you will need to set: # setsebool -P httpd_can_read_write_radicale=1 +# URI prefix: /radicale #Define RADICALE_SERVER_WSGI ### Extra options -## Apache starting a dedicated VHOST with SSL +## Apache starting a dedicated VHOST with SSL without "/radicale" prefix in URI on port 8443 #Define RADICALE_SERVER_VHOST_SSL @@ -27,8 +29,13 @@ #Define RADICALE_ENFORCE_SSL +### enable authentication by web server (config: [auth] type = http_x_remote_user) +#Define RADICALE_SERVER_USER_AUTHENTICATION + + ### Particular configuration EXAMPLES, adjust/extend/override to your needs + ########################## ### default host ########################## @@ -37,9 +44,14 @@ ## RADICALE_SERVER_REVERSE_PROXY RewriteEngine On + RewriteRule ^/radicale$ /radicale/ [R,L] - + RewriteCond %{REQUEST_METHOD} GET + RewriteRule ^/radicale/$ /radicale/.web/ [R,L] + + + # Internal WebUI does not need authentication at all RequestHeader set X-Script-Name /radicale RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" @@ -48,21 +60,40 @@ ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ - ## User authentication handled by "radicale" Require local Require all granted + - ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) - ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser - #AuthBasicProvider file - #AuthType Basic - #AuthName "Enter your credentials" - #AuthUserFile /etc/httpd/conf/htpasswd-radicale - #AuthGroupFile /dev/null - #Require valid-user - #RequestHeader set X-Remote-User expr=%{REMOTE_USER} + + RequestHeader set X-Script-Name /radicale + + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} + + ProxyPass http://localhost:5232/ retry=0 + ProxyPassReverse http://localhost:5232/ + + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + AuthBasicProvider file + AuthType Basic + AuthName "Enter your credentials" + AuthUserFile /etc/httpd/conf/htpasswd-radicale + AuthGroupFile /dev/null + Require valid-user + RequestHeader set X-Remote-User expr=%{REMOTE_USER} + @@ -70,7 +101,7 @@ SSLRequireSSL - + @@ -96,24 +127,38 @@ WSGIScriptAlias /radicale /usr/share/radicale/radicale.wsgi - + # Internal WebUI does not need authentication at all + RequestHeader set X-Script-Name /radicale - ## User authentication handled by "radicale" Require local Require all granted + + + + RequestHeader set X-Script-Name /radicale - ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) - ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser - #AuthBasicProvider file - #AuthType Basic - #AuthName "Enter your credentials" - #AuthUserFile /etc/httpd/conf/htpasswd-radicale - #AuthGroupFile /dev/null - #Require valid-user - #RequestHeader set X-Remote-User expr=%{REMOTE_USER} + + ## User authentication handled by "radicale" + Require local + + Require all granted + + + + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + AuthBasicProvider file + AuthType Basic + AuthName "Enter your credentials" + AuthUserFile /etc/httpd/conf/htpasswd-radicale + AuthGroupFile /dev/null + Require valid-user + RequestHeader set X-Remote-User expr=%{REMOTE_USER} + @@ -121,7 +166,7 @@ SSLRequireSSL - + Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled" @@ -165,30 +210,51 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" ## RADICALE_SERVER_REVERSE_PROXY - - RequestHeader set X-Script-Name / + RewriteEngine On + RewriteCond %{REQUEST_METHOD} GET + RewriteRule ^/$ /.web/ [R,L] + + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} ProxyPass http://localhost:5232/ retry=0 ProxyPassReverse http://localhost:5232/ - ## User authentication handled by "radicale" Require local Require all granted + + + + RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s" + RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} + + ProxyPass http://localhost:5232/ retry=0 + ProxyPassReverse http://localhost:5232/ + + + ## User authentication handled by "radicale" + Require local + + Require all granted + + - ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) - ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser - #AuthBasicProvider file - #AuthType Basic - #AuthName "Enter your credentials" - #AuthUserFile /etc/httpd/conf/htpasswd-radicale - #AuthGroupFile /dev/null - #Require valid-user - + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + AuthBasicProvider file + AuthType Basic + AuthName "Enter your credentials" + AuthUserFile /etc/httpd/conf/htpasswd-radicale + AuthGroupFile /dev/null + Require valid-user + RequestHeader set X-Remote-User expr=%{REMOTE_USER} + + @@ -214,24 +280,27 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" WSGIScriptAlias / /usr/share/radicale/radicale.wsgi - - RequestHeader set X-Script-Name / - - ## User authentication handled by "radicale" - Require local - - Require all granted + + + ## User authentication handled by "radicale" + Require local + + Require all granted + - ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) - ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser - #AuthBasicProvider file - #AuthType Basic - #AuthName "Enter your credentials" - #AuthUserFile /etc/httpd/conf/htpasswd-radicale - #AuthGroupFile /dev/null - #Require valid-user - + + ## You may want to use apache's authentication (config: [auth] type = http_x_remote_user) + ## e.g. create a new file with a testuser: htpasswd -c -B /etc/httpd/conf/htpasswd-radicale testuser + AuthBasicProvider file + AuthType Basic + AuthName "Enter your credentials" + AuthUserFile /etc/httpd/conf/htpasswd-radicale + AuthGroupFile /dev/null + Require valid-user + RequestHeader set X-Remote-User expr=%{REMOTE_USER} + + Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled" diff --git a/pyproject.toml b/pyproject.toml index eac75049..0f47effb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "Radicale" # When the version is updated, a new section in the CHANGELOG.md file must be # added too. readme = "README.md" -version = "3.4.2.dev" +version = "3.5.0.dev" authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}] license = {text = "GNU GPL v3"} description = "CalDAV and CardDAV Server" diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 7f8301f2..4e8e688b 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -68,6 +68,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _internal_server: bool _max_content_length: int _auth_realm: str + _script_name: str _extra_headers: Mapping[str, str] _permit_delete_collection: bool _permit_overwrite_collection: bool @@ -87,6 +88,19 @@ def __init__(self, configuration: config.Configuration) -> None: self._response_content_on_debug = configuration.get("logging", "response_content_on_debug") self._auth_delay = configuration.get("auth", "delay") self._internal_server = configuration.get("server", "_internal_server") + self._script_name = configuration.get("server", "script_name") + if self._script_name: + if self._script_name[0] != "/": + logger.error("server.script_name must start with '/': %r", self._script_name) + raise RuntimeError("server.script_name option has to start with '/'") + else: + if self._script_name.endswith("/"): + logger.error("server.script_name must not end with '/': %r", self._script_name) + raise RuntimeError("server.script_name option must not end with '/'") + else: + logger.info("Provided script name to strip from URI if called by reverse proxy: %r", self._script_name) + else: + logger.info("Default script name to strip from URI if called by reverse proxy is taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME") self._max_content_length = configuration.get( "server", "max_content_length") self._auth_realm = configuration.get("auth", "realm") @@ -178,14 +192,18 @@ def response(status: int, headers: types.WSGIResponseHeaders, # Return response content return status_text, list(headers.items()), answers + reverse_proxy = False remote_host = "unknown" if environ.get("REMOTE_HOST"): remote_host = repr(environ["REMOTE_HOST"]) elif environ.get("REMOTE_ADDR"): remote_host = environ["REMOTE_ADDR"] if environ.get("HTTP_X_FORWARDED_FOR"): + reverse_proxy = True remote_host = "%s (forwarded for %r)" % ( remote_host, environ["HTTP_X_FORWARDED_FOR"]) + if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"): + reverse_proxy = True remote_useragent = "" if environ.get("HTTP_USER_AGENT"): remote_useragent = " using %r" % environ["HTTP_USER_AGENT"] @@ -204,24 +222,37 @@ def response(status: int, headers: types.WSGIResponseHeaders, # SCRIPT_NAME is already removed from PATH_INFO, according to the # WSGI specification. # Reverse proxies can overwrite SCRIPT_NAME with X-SCRIPT-NAME header - base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in - environ else "SCRIPT_NAME") - base_prefix = environ.get(base_prefix_src, "") - if base_prefix and base_prefix[0] != "/": - logger.error("Base prefix (from %s) must start with '/': %r", - base_prefix_src, base_prefix) - if base_prefix_src == "HTTP_X_SCRIPT_NAME": - return response(*httputils.BAD_REQUEST) - return response(*httputils.INTERNAL_SERVER_ERROR) - if base_prefix.endswith("/"): - logger.warning("Base prefix (from %s) must not end with '/': %r", - base_prefix_src, base_prefix) - base_prefix = base_prefix.rstrip("/") - logger.debug("Base prefix (from %s): %r", base_prefix_src, base_prefix) + if self._script_name and (reverse_proxy is True): + base_prefix_src = "config" + base_prefix = self._script_name + else: + base_prefix_src = ("HTTP_X_SCRIPT_NAME" if "HTTP_X_SCRIPT_NAME" in + environ else "SCRIPT_NAME") + base_prefix = environ.get(base_prefix_src, "") + if base_prefix and base_prefix[0] != "/": + logger.error("Base prefix (from %s) must start with '/': %r", + base_prefix_src, base_prefix) + if base_prefix_src == "HTTP_X_SCRIPT_NAME": + return response(*httputils.BAD_REQUEST) + return response(*httputils.INTERNAL_SERVER_ERROR) + if base_prefix.endswith("/"): + logger.warning("Base prefix (from %s) must not end with '/': %r", + base_prefix_src, base_prefix) + base_prefix = base_prefix.rstrip("/") + if base_prefix: + logger.debug("Base prefix (from %s): %r", base_prefix_src, base_prefix) + # Sanitize request URI (a WSGI server indicates with an empty path, # that the URL targets the application root without a trailing slash) path = pathutils.sanitize_path(unsafe_path) logger.debug("Sanitized path: %r", path) + if (reverse_proxy is True) and (len(base_prefix) > 0): + if path.startswith(base_prefix): + path_new = path.removeprefix(base_prefix) + logger.debug("Called by reverse proxy, remove base prefix %r from path: %r => %r", base_prefix, path, path_new) + path = path_new + else: + logger.warning("Called by reverse proxy, cannot removed base prefix %r from path: %r as not matching", base_prefix, path) # Get function corresponding to method function = getattr(self, "do_%s" % request_method, None) diff --git a/radicale/app/get.py b/radicale/app/get.py index d8b01520..edd29b75 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -66,6 +66,8 @@ def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str, if path == "/.web" or path.startswith("/.web/"): # Redirect to sanitized path for all subpaths of /.web unsafe_path = environ.get("PATH_INFO", "") + if len(base_prefix) > 0: + unsafe_path = unsafe_path.removeprefix(base_prefix) if unsafe_path != path: location = base_prefix + path logger.info("Redirecting to sanitized path: %r ==> %r", diff --git a/radicale/config.py b/radicale/config.py index 6a218160..f13a8d6a 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -187,6 +187,10 @@ def json_str(value: Any) -> dict: "help": "set CA certificate for validating clients", "aliases": ("--certificate-authority",), "type": filepath}), + ("script_name", { + "value": "", + "help": "script name to strip from URI if called by reverse proxy (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)", + "type": str}), ("_internal_server", { "value": "False", "help": "the internal server is used", @@ -203,7 +207,7 @@ def json_str(value: Any) -> dict: ("auth", OrderedDict([ ("type", { "value": "none", - "help": "authentication method", + "help": "authentication method (" + "|".join(auth.INTERNAL_TYPES) + ")", "type": str_or_callable, "internal": auth.INTERNAL_TYPES}), ("cache_logins", { diff --git a/radicale/storage/multifilesystem/move.py b/radicale/storage/multifilesystem/move.py index 7b1eb490..3eb5cee0 100644 --- a/radicale/storage/multifilesystem/move.py +++ b/radicale/storage/multifilesystem/move.py @@ -2,7 +2,7 @@ # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2021 Unrud -# Copyright © 2024-2024 Peter Bieringer +# Copyright © 2024-2025 Peter Bieringer # # 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 @@ -21,6 +21,7 @@ from radicale import item as radicale_item from radicale import pathutils, storage +from radicale.log import logger from radicale.storage import multifilesystem from radicale.storage.multifilesystem.base import StorageBase @@ -34,10 +35,12 @@ def move(self, item: radicale_item.Item, assert isinstance(to_collection, multifilesystem.Collection) assert isinstance(item.collection, multifilesystem.Collection) assert item.href - os.replace(pathutils.path_to_filesystem( - item.collection._filesystem_path, item.href), - pathutils.path_to_filesystem( - to_collection._filesystem_path, to_href)) + move_from = pathutils.path_to_filesystem(item.collection._filesystem_path, item.href) + move_to = pathutils.path_to_filesystem(to_collection._filesystem_path, to_href) + try: + os.replace(move_from, move_to) + except OSError as e: + raise ValueError("Failed to move file %r => %r %s" % (move_from, move_to, e)) from e self._sync_directory(to_collection._filesystem_path) if item.collection._filesystem_path != to_collection._filesystem_path: self._sync_directory(item.collection._filesystem_path) @@ -45,11 +48,15 @@ def move(self, item: radicale_item.Item, cache_folder = self._get_collection_cache_subfolder(item.collection._filesystem_path, ".Radicale.cache", "item") to_cache_folder = self._get_collection_cache_subfolder(to_collection._filesystem_path, ".Radicale.cache", "item") self._makedirs_synced(to_cache_folder) + move_from = os.path.join(cache_folder, item.href) + move_to = os.path.join(to_cache_folder, to_href) try: - os.replace(os.path.join(cache_folder, item.href), - os.path.join(to_cache_folder, to_href)) + os.replace(move_from, move_to) except FileNotFoundError: pass + except OSError as e: + logger.error("Failed to move cache file %r => %r %s" % (move_from, move_to, e)) + pass else: self._makedirs_synced(to_cache_folder) if cache_folder != to_cache_folder: diff --git a/setup.py.legacy b/setup.py.legacy index 09d323a9..aca304f5 100644 --- a/setup.py.legacy +++ b/setup.py.legacy @@ -20,7 +20,7 @@ from setuptools import find_packages, setup # When the version is updated, a new section in the CHANGELOG.md file must be # added too. -VERSION = "3.4.2.dev" +VERSION = "3.5.0.dev" with open("README.md", encoding="utf-8") as f: long_description = f.read()