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()