Просмотр исходного кода

Merge pull request #1720 from pbiering/improvements-2

Adjustments related to reverse proxy
Peter Bieringer 1 год назад
Родитель
Сommit
b729a4c192

+ 4 - 1
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

+ 6 - 0
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

+ 3 - 0
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]
 

+ 122 - 53
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
 <IfDefine RADICALE_SERVER_REVERSE_PROXY>
 	RewriteEngine On
+
 	RewriteRule ^/radicale$ /radicale/ [R,L]
 
-	<Location /radicale>
+	RewriteCond %{REQUEST_METHOD} GET
+	RewriteRule ^/radicale/$ /radicale/.web/ [R,L]
+
+	<LocationMatch "^/radicale/\.web.*>
+		# 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
 		<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
 		Require all granted
 		</IfDefine>
+	</LocationMatch>
 
-		## 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}
+	<LocationMatch "^/radicale(?!/\.web)">
+		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/
+
+		<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
+			## User authentication handled by "radicale"
+			Require local
+			<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
+			Require all granted
+			</IfDefine>
+		</IfDefine>
+
+		<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
+			## 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}
+		</IfDefine>
 
 		<IfDefine RADICALE_ENFORCE_SSL>
 			<IfModule !ssl_module>
@@ -70,7 +101,7 @@
 			</IfModule>
 			SSLRequireSSL
 		</IfDefine>
-	</Location>
+	</LocationMatch>
 </IfDefine>
 
 
@@ -96,24 +127,38 @@
 
 	WSGIScriptAlias /radicale /usr/share/radicale/radicale.wsgi
 
-	<Location /radicale>
+	# Internal WebUI does not need authentication at all
+	<LocationMatch "^/radicale/\.web.*>
 		RequestHeader    set X-Script-Name /radicale
 
-		## User authentication handled by "radicale"
 		Require local
 		<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
 		Require all granted
 		</IfDefine>
+	</LocationMatch>
+
+	<LocationMatch "^/radicale(?!/\.web)">
+		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}
+		<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
+			## User authentication handled by "radicale"
+			Require local
+			<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
+			Require all granted
+			</IfDefine>
+		</IfDefine>
+
+		<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
+			## 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}
+		</IfDefine>
 
 		<IfDefine RADICALE_ENFORCE_SSL>
 			<IfModule !ssl_module>
@@ -121,7 +166,7 @@
 			</IfModule>
 			SSLRequireSSL
 		</IfDefine>
-	</Location>
+	</LocationMatch>
 </IfModule>
 <IfModule !wsgi_module>
 	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
 <IfDefine RADICALE_SERVER_REVERSE_PROXY>
-	<Location />
-		RequestHeader    set X-Script-Name /
+	RewriteEngine On
 
+	RewriteCond %{REQUEST_METHOD} GET
+	RewriteRule ^/$ /.web/ [R,L]
+
+	<LocationMatch "^/\.web.*>
 		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
 		<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
 		Require all granted
 		</IfDefine>
+	</LocationMatch>
+
+	<LocationMatch "^(?!/\.web)">
+		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/
+
+		<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
+			## User authentication handled by "radicale"
+			Require local
+			<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
+			Require all granted
+			</IfDefine>
+		</IfDefine>
 
-		## 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
-	</Location>
+		<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
+			## 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}
+		</IfDefine>
+	</LocationMatch>
 </IfDefine>
 
 
@@ -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
 
-	<Location />
-		RequestHeader    set X-Script-Name /
-
-		## User authentication handled by "radicale"
-		Require local
-		<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
-		Require all granted
+	<LocationMatch "^/(?!/\.web)">
+		<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
+			## User authentication handled by "radicale"
+			Require local
+			<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
+			Require all granted
+			</IfDefine>
 		</IfDefine>
 
-		## 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
-	</Location>
+		<IfDefine RADICALE_SERVER_USER_AUTHENTICATION>
+			## 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}
+		</IfDefine>
+	</LocationMatch>
 </IfModule>
 <IfModule !wsgi_module>
 	Error "RADICALE_SERVER_WSGI selected but wsgi module not loaded/enabled"

+ 1 - 1
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"

+ 45 - 14
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 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
         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 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
             # 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 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
         # 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)

+ 2 - 0
radicale/app/get.py

@@ -66,6 +66,8 @@ class ApplicationPartGet(ApplicationBase):
         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",

+ 5 - 1
radicale/config.py

@@ -187,6 +187,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
             "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 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
     ("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", {

+ 14 - 7
radicale/storage/multifilesystem/move.py

@@ -2,7 +2,7 @@
 # Copyright © 2014 Jean-Marc Martins
 # Copyright © 2012-2017 Guillaume Ayoub
 # Copyright © 2017-2021 Unrud <unrud@outlook.com>
-# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
+# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
 #
 # 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 @@ import os
 
 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 @@ class StoragePartMove(StorageBase):
         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 @@ class StoragePartMove(StorageBase):
         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:

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