Subject: [bug#69719] [PATCH v2] services: radicale: Use define-configuration.

From: Juliana Sims <[email-masked]>
Date: Mon, 11 Mar 2024 21:14:06 -0400

Hi,

First and foremost, thanks for such a quick review!

Last night as I was going to sleep I thought to myself, "I feel like there's
something I forgot to check in that patch." And this morning I woke up to the
thought, "I forgot to test serializing headers!" Importantly, I'd forgotten to
write out the section header during serialization. Further, the text-config type
I was using expects a list rather than individual file-like objects. I've
changed the code so the headers-file field now accepts a single file-like
object.

However, in figuring out how best to do this, I noticed that the dovecot
configuration uses fields with the file-like type, but exclusively for the
dovecot package. The documentation refers to these fields as accepting a
package, and the default value is the dovecot package. I assumed the code
predates packages as a distinct type so I ran a git blame -- and it turns out
that type is used so inferior packages can be used as packages. I've changed the
type of the package field to reflect this new information, though I've left the
documentation referring to the correct type.

Speaking of documentation and correct types, I changed the documentation for
this code to refer to the expected type without the maybe even if that's not the
name used in the code itself. I also added default values that match the effect
of not providing anything to the field and made a note that the defaults all
match the upstream documentation. That way users know what they're getting
without having to check outside the Guix docs, and they don't need to know about
implementation details.

While doing that, I decided that I should actually ensure the list of IP
addresses is a list of IP addresses, so a defined a predicate to check for that
and setup a new type to use it.

I also noticed that my section serializers could just use the field name as the
section header instead of passing that manually. I've modified that code reflect
this insight.

As a last note not directly related to this review, I found out about
mkdir-p/perms and rewrote radicale-activation to use it. That's a handy little
tidbit that probably ought to be documented -- although it seems it can
introduce some security vulnerability or other which may be why it's not.

> You should not need to quote symbols here.

I agree. Unfortunately, the experience of running the code does not ;) Someone
in the Matrix also suggested this situation was normal for g-expressions. This
may be a bug, but that's outside of knowledge domain.

> Note the mismatch between documented and stored type: Your type is
> "maybe-symbol", but you store strings. Instead, you can a) move the the logic
> to uglify the name to radicale-serialize-symbol or define a
> radicale-serialize-uglified-symbol variant if you need the normal
> radicale-serialize-symbol, or b) document that you actually take strings. Of
> course, you can also think of other solutions.
>
> Hint: I'd personally prefer solution a).

I've decided to convert the uglified symbols back into actual symbols before
returning them from the sanitizer. This seems to resolve the issue I was having
with serializers not being called. For some reason both of the other options you
propose bother me for largely stylistic reasons. I don't want to accept strings
because arbitrary strings aren't acceptable; only a concrete set of values. I
associate those semantics with symbols. Similarly, defining a separate
serializer just for these symbols feels wrong because the other symbols are
similarly not arbitrary and thus there is no meaningful semantic distinction. I
would define those other symbols (the encoding fields) as delimited if I knew
what the valid options for them were, but Radicale doesn't document the
available options.

I've run another set of tests on this code to make sure all of it works as
expected -- including the HTTP headers this time :)

Thanks again,
Juli

* doc/guix.texi (radicale-configuration): Update documentation to reflect new
configuration, add new symbols.
* gnu/services/mail.scm (%default-radicale-config-file): Delete.
(radicale-auth-configuration, radicale-auth-configuration?)
(radicale-encoding-configuration, radicale-encoding-configuration?)
(radicale-logging-configuration, radicale-logging-configuration?)
(radicale-rights-configuration, radicale-rights-configuration?)
(radicale-server-configuration, radicale-server-configuration?)
(radicale-storage-configuration, radicale-storage-configuration?): New symbol.
(radicale-configuration, radicale-configuration?): Use define-configuration.
(radicale-activation, radicale-shepherd-service): Update for new
configuration format.
(radicale-activation): Use user-defined values for service files.
(radicale-service-type): Capitalize "Radicale" in description.

Change-Id: Ic88b8ff2750e3d658f6c7cee02d33417aa8ee6d2
---
 doc/guix.texi         | 188 ++++++++++++++++++++-
 gnu/services/mail.scm | 368 +++++++++++++++++++++++++++++++++++++-----
 2 files changed, 511 insertions(+), 45 deletions(-)

diff --git a/doc/guix.texi b/doc/guix.texi
index 858d5751bfe..3c6920ee569 100644
--- a/doc/guix.texi
+++ b/doc/guix.texi
@@ -28248,23 +28248,195 @@ Mail Services
 @cindex CardDAV
 
 @defvar radicale-service-type
-This is the type of the @uref{https://radicale.org, Radicale} CalDAV/CardDAV
-server whose value should be a @code{radicale-configuration}.
+This is the type of the @uref{https://radicale.org, Radicale}
+CalDAV/CardDAV server whose value should be a
+@code{radicale-configuration}.  The default configuration matches the
+@uref{https://radicale.org/v3.html#configuration, upstream
+documentation}.
 @end defvar
 
 @deftp {Data Type} radicale-configuration
 Data type representing the configuration of @command{radicale}.
+Available @code{radicale-configuration} fields are:
 
 @table @asis
-@item @code{package} (default: @code{radicale})
-The package that provides @command{radicale}.
+@item @code{package} (default: @code{radicale}) (type: package)
+Package that provides @command{radicale}.
 
-@item @code{config-file} (default: @code{%default-radicale-config-file})
-File-like object of the configuration file to use, by default it will listen
-on TCP port 5232 of @code{localhost} and use the @code{htpasswd} file at
-@file{/var/lib/radicale/users} with no (@code{plain}) encryption.
+@item @code{auth} (default: @code{'()}) (type: radicale-auth-configuration)
+Configuration for auth-related variables.
+
+@deftp {Data Type} radicale-auth-configuration
+Data type representing the @code{auth} section of a @command{radicale}
+configuration file.  Available @code{radicale-auth-configuration} fields
+are:
+
+@table @asis
+@item @code{type} (default: @code{'none}) (type: symbol)
+The method to verify usernames and passwords.  Options are @code{none},
+@code{htpasswd}, @code{remote-user}, and @code{http-x-remote-user}.
+This value is tied to @code{htpasswd-filename} and
+@code{htpasswd-encryption}.
+
+@item @code{htpasswd-filename} (default: @code{"/etc/radicale/users"}) (type: file-name)
+Path to the htpasswd file.  Use htpasswd or similar to generate this
+file.
+
+@item @code{htpasswd-encryption} (default: @code{'md5}) (type: symbol)
+Encryption method used in the htpasswd file.  Options are @code{plain},
+@code{bcrypt}, and @code{md5}.
+
+@item @code{delay} (default: @code{1}) (type: non-negative-integer)
+Average delay after failed login attempts in seconds.
+
+@item @code{realm} (default: @code{"Radicale - Password Required"}) (type: string)
+Message displayed in the client when a password is needed.
+
+@end table
+
+@end deftp
+
+@item @code{encoding} (default: @code{'()}) (type: radicale-encoding-configuration)
+Configuration for encoding-related variables.
+
+@deftp {Data Type} radicale-encoding-configuration
+Data type representing the @code{encoding} section of a
+@command{radicale} configuration file.  Available
+@code{radicale-encoding-configuration} fields are:
+
+@table @asis
+@item @code{request} (default: @code{'utf-8}) (type: symbol)
+Encoding for responding requests.
+
+@item @code{stock} (default: @code{'utf-8}) (type: symbol)
+Encoding for storing local collections.
+
+@end table
+
+@end deftp
+
+@item @code{headers-file} (default: none) (type: file-like)
+Custom HTTP headers.
+
+@item @code{logging} (default: @code{'()}) (type: radicale-logging-configuration)
+Configuration for logging-related variables.
+
+@deftp {Data Type} radicale-logging-configuration
+Data type representing the @code{logging} section of a
+@command{radicale} configuration file.  Available
+@code{radicale-logging-configuration} fields are:
+
+@table @asis
+@item @code{level} (default: @code{'warning}) (type: symbol)
+Set the logging level.  One of @code{debug}, @code{info},
+@code{warning}, @code{error}, or @code{critical}.
+
+@item @code{mask-passwords?} (default: @code{#t}) (type: boolean)
+Whether to include passwords in logs.
+
+@end table
+
+@end deftp
+
+@item @code{rights} (default: @code{'()}) (type: radicale-rights-configuration)
+Configuration for rights-related variables.  This should be a
+@code{radicale-rights-configuration}.
+
+@deftp {Data Type} radicale-rights-configuration
+Data type representing the @code{rights} section of a @command{radicale}
+configuration file.  Available @code{radicale-rights-configuration}
+fields are:
+
+@table @asis
+@item @code{type} (default: @code{'owner-only}) (type: symbol)
+Backend used to check collection access rights.  The recommended backend
+is @code{owner-only}.  If access to calendars and address books outside
+the home directory of users is granted, clients won't detect these
+collections and will not show them to the user.  Choosing any other
+method is only useful if you access calendars and address books directly
+via URL.  Options are @code{authenticate}, @code{owner-only},
+@code{owner-write}, and @code{from-file}.
+
+@item @code{file} (default: @code{""}) (type: file-name)
+File for the rights backend @code{from-file}.
+
+@end table
+
+@end deftp
+
+@item @code{server} (default: @code{'()}) (type: radicale-server-configuration)
+Configuration for server-related variables.  Ignored if WSGI is used.
+
+@deftp {Data Type} radicale-server-configuration
+Data type representing the @code{server} section of a @command{radicale}
+configuration file.  Available @code{radicale-server-configuration}
+fields are:
+
+@table @asis
+@item @code{hosts} (default: @code{(list "localhost:5232")}) (type: list-of-ip-addresses)
+List of IP addresses that the server will bind to.
+
+@item @code{max-connections} (default: @code{8}) (type: non-negative-integer)
+Maximum number of parallel connections.  Set to 0 to disable the limit.
+
+@item @code{max-content-length} (default: @code{100000000}) (type: non-negative-integer)
+Maximum size of the request body in bytes.
+
+@item @code{timeout} (default: @code{30}) (type: non-negative-integer)
+Socket timeout in seconds.
+
+@item @code{ssl?} (default: @code{#f}) (type: boolean)
+Whether to enable transport layer encryption.
+
+@item @code{certificate} (default: @code{"/etc/ssl/radicale.cert.pem"}) (type: file-name)
+Path of the SSL certificate.
+
+@item @code{key} (default: @code{"/etc/ssl/radicale.key.pem"}) (type: file-name)
+Path to the private key for SSL.  Only effective if @code{ssl?} is
+@code{#t}.
+
+@item @code{certificate-authority} (default: @code{""}) (type: file-name)
+Path to CA certificate for validating client certificates.  This can be
+used to secure TCP traffic between Radicale and a reverse proxy.  If you
+want to authenticate users with client-side certificates, you also have
+to write an authentication plugin that extracts the username from the
+certificate.
+
+@end table
+
+@end deftp
+
+@item @code{storage} (default: @code{'()}) (type: radicale-storage-configuration)
+Configuration for storage-related variables.
+
+@deftp {Data Type} radicale-storage-configuration
+Data type representing the @code{storage} section of a
+@command{radicale} configuration file.  Available
+@code{radicale-storage-configuration} fields are:
+
+@table @asis
+@item @code{type} (default: @code{'multifilesystem}) (type: symbol)
+Backend used to store data.  Options are @code{multifilesystem} and
+@code{multifilesystem-nolock}.
+
+@item @code{filesystem-folder} (default: @code{"/var/lib/radicale/collections"}) (type: file-name)
+Folder for storing local collections.  Created if not present.
+
+@item @code{max-sync-token-age} (default: @code{2592000}) (type: non-negative-integer)
+Delete sync-tokens that are older than the specified time in seconds.
+
+@item @code{hook} (default: @code{""}) (type: string)
+Command run after changes to storage.
 
 @end table
+
+@end deftp
+
+@item @code{web-interface?} (default: @code{#t}) (type: boolean)
+Whether to use Radicale's built-in web interface.
+
+@end table
+
 @end deftp
 
 @subsubheading Rspamd Service
diff --git a/gnu/services/mail.scm b/gnu/services/mail.scm
index afe1bb60169..9b4bfd360fc 100644
--- a/gnu/services/mail.scm
+++ b/gnu/services/mail.scm
@@ -7,6 +7,7 @@
 ;;; Copyright © 2020 Jonathan Brielmaier <[email-masked]>
 ;;; Copyright © 2023 Thomas Ieong <[email-masked]>
 ;;; Copyright © 2023 Saku Laesvuori <[email-masked]>
+;;; Copyright © 2024 Juliana Sims <[email-masked]>
 ;;;
 ;;; This file is part of GNU Guix.
 ;;;
@@ -38,10 +39,12 @@ (define-module (gnu services mail)
   #:use-module (gnu packages dav)
   #:use-module (gnu packages tls)
   #:use-module (guix deprecation)
+  #:use-module ((guix diagnostics) #:select (source-properties->location))
   #:use-module (guix modules)
   #:use-module (guix records)
   #:use-module (guix packages)
   #:use-module (guix gexp)
+  #:use-module (ice-9 curried-definitions)
   #:use-module (ice-9 match)
   #:use-module (ice-9 format)
   #:use-module (srfi srfi-1)
@@ -79,10 +82,21 @@ (define-module (gnu services mail)
             imap4d-service-type
             %default-imap4d-config-file
 
+            radicale-auth-configuration
+            radicale-auth-configuration?
+            radicale-encoding-configuration
+            radicale-encoding-configuration?
+            radicale-logging-configuration
+            radicale-logging-configuration?
+            radicale-rights-configuration
+            radicale-rights-configuration?
+            radicale-server-configuration
+            radicale-server-configuration?
+            radicale-storage-configuration
+            radicale-storage-configuration?
             radicale-configuration
             radicale-configuration?
             radicale-service-type
-            %default-radicale-config-file
 
             rspamd-configuration
             rspamd-service-type
@@ -1929,23 +1943,258 @@ (define imap4d-service-type
 ;;; Radicale.
 ;;;
 
-(define-record-type* <radicale-configuration>
-  radicale-configuration make-radicale-configuration
-  radicale-configuration?
-  (package     radicale-configuration-package
-               (default radicale))
-  (config-file radicale-configuration-config-file
-               (default %default-radicale-config-file)))
+;; Maybe types
 
-(define %default-radicale-config-file
-  (plain-file "radicale.conf" "
-[auth]
-type = htpasswd
-htpasswd_filename = /var/lib/radicale/users
-htpasswd_encryption = plain
+(define (comma-separated-ip-list? lst)
+  (every (lambda (s)
+           (or (string-prefix? "localhost" s)
+               ((@@ (gnu services vpn) ipv4-address?) s)
+               ((@@ (gnu services vpn) ipv6-address?) s)))
+         lst))
 
-[server]
-hosts = localhost:5232"))
+(define-maybe boolean (prefix radicale-))
+(define-maybe comma-separated-ip-list (prefix radicale-))
+(define-maybe file-name (prefix radicale-))
+(define-maybe non-negative-integer (prefix radicale-))
+(define-maybe string (prefix radicale-))
+(define-maybe symbol (prefix radicale-))
+
+;; Serializers and sanitizers
+
+(define (radicale-serialize-field field-name value)
+  ;; XXX We quote the un-gexp form here because otherwise symbol-literals are
+  ;; treated as variables. We can get away with this because all of our other
+  ;; field value types are primitives by the time they get here so are printed
+  ;; the same whether or not they are quoted.
+  #~(format #f "~a = ~a\n" #$(uglify-field-name field-name) '#$value))
+
+(define (radicale-serialize-boolean field-name value?)
+  (radicale-serialize-field field-name (if value? "True" "False")))
+
+(define (radicale-serialize-comma-separated-ip-list field-name value)
+  (radicale-serialize-field field-name (string-join value ", ")))
+
+(define radicale-serialize-file-name radicale-serialize-field)
+
+(define radicale-serialize-non-negative-integer radicale-serialize-field)
+
+(define radicale-serialize-string radicale-serialize-field)
+
+(define radicale-serialize-symbol radicale-serialize-field)
+
+(define ((sanitize-delimited-symbols syms location field) value)
+  (cond
+   ((not (maybe-value-set? value))
+    value)
+   ((member value syms)
+    (string->symbol (uglify-field-name value)))
+   (else
+    (configuration-field-error (source-properties->location location)
+                               field
+                               value))))
+
+;; Section configuration types
+
+(define-configuration radicale-auth-configuration
+  (type
+   maybe-symbol
+   "The method to verify usernames and passwords. Options are @code{none},
+@code{htpasswd}, @code{remote-user}, and @code{http-x-remote-user}.
+
+This value is tied to @code{htpasswd-filename} and @code{htpasswd-encryption}."
+   (sanitizer
+    (sanitize-delimited-symbols '(none htpasswd remote-user http-x-remote-user)
+                                (current-source-location)
+                                'type)))
+  (htpasswd-filename
+   maybe-file-name
+   "Path to the htpasswd file. Use htpasswd or similar to generate this file.")
+  (htpasswd-encryption
+   maybe-symbol
+   "Encryption method used in the htpasswd file. Options are @code{plain},
+@code{bcrypt}, and @code{md5}."
+   (sanitizer
+    (sanitize-delimited-symbols '(plain bcrypt md5)
+                                (current-source-location)
+                                'htpasswd-encryption)))
+  (delay
+   maybe-non-negative-integer
+   "Average delay after failed login attempts in seconds.")
+  (realm
+   maybe-string
+   "Message displayed in the client when a password is needed.")
+  (prefix radicale-))
+
+(define-configuration radicale-encoding-configuration
+  (request
+   maybe-symbol
+   "Encoding for responding requests.")
+  (stock
+   maybe-symbol
+   "Encoding for storing local collections.")
+  (prefix radicale-))
+
+(define-configuration radicale-logging-configuration
+  (level
+   maybe-symbol
+   "Set the logging level. One of @code{debug}, @code{info}, @code{warning},
+@code{error}, or @code{critical}."
+   (sanitizer (sanitize-delimited-symbols '(debug info warning error critical)
+                                          (current-source-location)
+                                          'level)))
+  (mask-passwords?
+   maybe-boolean
+   "Whether to include passwords in logs.")
+  (prefix radicale-))
+
+(define-configuration radicale-rights-configuration
+  (type
+   maybe-symbol
+   "Backend used to check collection access rights. The recommended backend is
+@code{owner-only}. If access to calendars and address books outside the home
+directory of users is granted, clients won't detect these collections and will
+not show them to the user. Choosing any other method is only useful if you
+access calendars and address books directly via URL. Options are
+@code{authenticate}, @code{owner-only}, @code{owner-write}, and
+@code{from-file}."
+   (sanitizer
+    (sanitize-delimited-symbols '(authenticate owner-only owner-write from-file)
+                                (current-source-location)
+                                'type)))
+  (file
+   maybe-file-name
+   "File for the rights backend @code{from-file}.")
+  (prefix radicale-))
+
+(define-configuration radicale-server-configuration
+  (hosts
+   maybe-comma-separated-ip-list
+   "List of IP addresses that the server will bind to.")
+  (max-connections
+   maybe-non-negative-integer
+   "Maximum number of parallel connections. Set to 0 to disable the limit.")
+  (max-content-length
+   maybe-non-negative-integer
+   "Maximum size of the request body in byetes.")
+  (timeout
+   maybe-non-negative-integer
+   "Socket timeout in seconds.")
+  (ssl?
+   maybe-boolean
+   "Whether to enable transport layer encryption.")
+  (certificate
+   maybe-file-name
+   "Path of the SSL certificate.")
+  (key
+   maybe-file-name
+   "Path to the private key for SSL. Only effective if @code{ssl?} is
+@code{#t}.")
+  (certificate-authority
+   maybe-file-name
+   "Path to CA certificate for validating client certificates. This can be used
+to secure TCP traffic between Radicale and a reverse proxy. If you want to
+authenticate users with client-side certificates, you also have to write an
+authentication plugin that extracts the username from the certificate.")
+  (prefix radicale-))
+
+(define-configuration radicale-storage-configuration
+  (type
+   maybe-symbol
+   "Backend used to store data. Options are @code{multifilesystem} and
+@code{multifilesystem-nolock}."
+   (sanitizer
+    (sanitize-delimited-symbols '(multifilesystem multifilesystem-nolock)
+                                (current-source-location)
+                                'type)))
+  (filesystem-folder
+   maybe-file-name
+   "Folder for storing local collections. Created if not present.")
+  (max-sync-token-age
+   maybe-non-negative-integer
+   "Delete sync-tokens that are older than the specified time in seconds.")
+  (hook
+   maybe-string
+   "Command run after changes to storage.")
+  (prefix radicale-))
+
+;; Helpers for using section configurations in the main configuration
+
+;; XXX These indirections are necessary to avoid creating semantic ambiguity
+(define auth-config? radicale-auth-configuration?)
+(define encoding-config? radicale-encoding-configuration?)
+(define headers-file? file-like?)
+(define logging-config? radicale-logging-configuration?)
+(define rights-config? radicale-rights-configuration?)
+(define server-config? radicale-server-configuration?)
+(define storage-config? radicale-storage-configuration?)
+
+(define-maybe auth-config)
+(define-maybe encoding-config)
+(define-maybe headers-file)
+(define-maybe logging-config)
+(define-maybe rights-config)
+(define-maybe server-config)
+(define-maybe storage-config)
+
+(define ((serialize-radicale-section fields) name cfg)
+  #~(format #f "[~a]\n~a\n" '#$name #$(serialize-configuration cfg fields)))
+
+(define serialize-auth-config
+  (serialize-radicale-section radicale-auth-configuration-fields))
+(define serialize-encoding-config
+  (serialize-radicale-section radicale-encoding-configuration-fields))
+(define serialize-logging-config
+  (serialize-radicale-section radicale-logging-configuration-fields))
+(define serialize-rights-config
+  (serialize-radicale-section radicale-rights-configuration-fields))
+(define serialize-server-config
+  (serialize-radicale-section radicale-server-configuration-fields))
+(define serialize-storage-config
+  (serialize-radicale-section radicale-storage-configuration-fields))
+
+(define (serialize-radicale-configuration cfg)
+  (mixed-text-file
+   "radicale.conf"
+   (serialize-configuration cfg radicale-configuration-fields)))
+
+(define-configuration radicale-configuration
+  ;; Only fields whose default value does not match upstream are not maybe-types
+  (package
+   (file-like radicale)
+   "Package that provides @command{radicale}.")
+  (auth
+   maybe-auth-config
+   "Configuration for auth-related variables.")
+  (encoding
+   maybe-encoding-config
+   "Configuration for encoding-related variables.")
+  (headers-file
+   maybe-headers-file
+   "Custom HTTP headers."
+   (serializer
+    (lambda (field-name value)
+      #~(begin
+          (use-modules (ice-9 rdelim))
+          (format #f "[headers]\n~a\n\n"
+                  (with-input-from-file #$value read-string))))))
+  (logging
+   maybe-logging-config
+   "Configuration for logging-related variables.")
+  (rights
+   maybe-rights-config
+   "Configuration for rights-related variables.")
+  (server
+   maybe-server-config
+   "Configuration for server-related variables. Ignored if WSGI is used.")
+  (storage
+   maybe-storage-config
+   "Configuration for storage-related variables.")
+  (web-interface?
+   maybe-boolean
+   "Whether to use Radicale's built-in web interface."
+   (serializer
+    (lambda (_ use?)
+      #~(format #f "[web]\ntype = ~a\n\n" #$(if use? "internal" "none"))))))
 
 (define %radicale-accounts
   (list (user-group
@@ -1959,43 +2208,88 @@ (define %radicale-accounts
          (home-directory "/var/empty")
          (shell (file-append shadow "/sbin/nologin")))))
 
-(define radicale-shepherd-service
-  (match-lambda
-    (($ <radicale-configuration> package config-file)
-     (list (shepherd-service
-            (provision '(radicale))
-            (documentation "Run the radicale daemon.")
-            (requirement '(networking))
-            (start #~(make-forkexec-constructor
-                      (list #$(file-append package "/bin/radicale")
-                        "-C" #$config-file)
-                      #:user "radicale"
-                      #:group "radicale"))
-            (stop #~(make-kill-destructor)))))))
+(define (radicale-shepherd-service cfg)
+  (list (shepherd-service
+         (provision '(radicale))
+         (documentation "Run the radicale daemon.")
+         (requirement '(networking))
+         (start #~(make-forkexec-constructor
+                   (list #$(file-append (radicale-configuration-package cfg)
+                                        "/bin/radicale")
+                         "-C" #$(serialize-radicale-configuration cfg))
+                   #:user "radicale"
+                   #:group "radicale"))
+         (stop #~(make-kill-destructor)))))
 
 (define radicale-activation
   (match-lambda
-    (($ <radicale-configuration> package config-file)
+    (($ <radicale-configuration> _ auth-config _ _ _ _ _ storage-config _)
+     ;; Get values for the collections directory
+     ;; See https://radicale.org/v3.html#running-as-a-service
+     (define filesystem-folder-val
+       (if (maybe-value-set? storage-config)
+           (radicale-storage-configuration-filesystem-folder storage-config)
+           storage-config))
+     (define collections-dir
+       (if (maybe-value-set? filesystem-folder-val)
+           filesystem-folder-val
+           "/var/lib/radicale/collections"))
+     (define collections-parent-dir (dirname collections-dir))
+     ;; Get values for the password file directory
+     (define auth-value-set? (maybe-value-set? auth-config))
+     ;; If auth's type is 'none or unset, that means there is no authentication
+     ;; and we don't need to setup files for it
+     (define auth?
+       (and auth-value-set?
+            (not (eq? (radicale-auth-configuration-type auth-config) 'none))))
+     (define password-file-val
+       (if auth-value-set?
+           (radicale-auth-configuration-htpasswd-filename auth-config)
+           auth-config))
+     (define password-file-dir
+       (if (maybe-value-set? password-file-val)
+           (dirname password-file-val)
+           "/etc/radicale"))
      (with-imported-modules '((guix build utils))
        #~(begin
            (use-modules (guix build utils))
-           (let ((uid (passwd:uid (getpw "radicale")))
-                 (gid (group:gid (getgr "radicale"))))
-             (mkdir-p "/var/lib/radicale/collections")
-             (chown "/var/lib/radicale" uid gid)
-             (chown "/var/lib/radicale/collections" uid gid)
-             (chmod "/var/lib/radicale" #o700)))))))
+           (let ((user (getpwnam "radicale")))
+             ;; Collections directory perms
+             (mkdir-p/perms #$collections-dir user #o700)
+             ;; Password file perms
+             (when #$auth?
+               ;; In theory, the password file and thus this directory should already
+               ;; exist because the user has to make them by hand
+               (mkdir-p/perms #$password-file-dir user #o700))))))))
 
 (define radicale-service-type
   (service-type
    (name 'radicale)
-   (description "Run radicale, a small CalDAV and CardDAV server.")
+   (description "Run Radicale, a small CalDAV and CardDAV server.")
    (extensions
     (list (service-extension shepherd-root-service-type radicale-shepherd-service)
           (service-extension account-service-type (const %radicale-accounts))
           (service-extension activation-service-type radicale-activation)))
    (default-value (radicale-configuration))))
 
+(define (generate-radicale-documentation)
+  (generate-documentation
+   `((radicale-configuration
+      ,radicale-configuration-fields
+      (auth     radicale-auth-configuration)
+      (encoding radicale-encoding-configuration)
+      (logging  radicale-logging-configuration)
+      (rights   radicale-rights-configuration)
+      (server   radicale-server-configuration)
+      (storage  radicale-storage-configuration))
+     (radicale-auth-configuration     ,radicale-auth-configuration-fields)
+     (radicale-encoding-configuration ,radicale-encoding-configuration-fields)
+     (radicale-logging-configuration  ,radicale-logging-configuration-fields)
+     (radicale-rights-configuration   ,radicale-rights-configuration-fields)
+     (radicale-server-configuration   ,radicale-server-configuration-fields)
+     (radicale-storage-configuration  ,radicale-storage-configuration-fields))
+   'radicale-configuration))
+
 ;;;
 ;;; Rspamd.
 ;;;

base-commit: 4003c60abf7a6e59e47cc2deb9eef2f104ebb994
-- 
2.41.0