[bug#77288,5/6] services: guix: Allow ‘guix-daemon’ to run without root privileges.

Message ID e9ae6efd47c7098671c96a694a1b221c5511c6a4.1743007256.git.ludo@gnu.org
State New
Headers
Series Rootless guix-daemon on Guix System |

Commit Message

Ludovic Courtès March 26, 2025, 4:51 p.m. UTC
  * gnu/services/base.scm (run-with-writable-store)
(guix-ownership-change-program): New procedures.
(<guix-configuration>)[privileged?]: New field.
(guix-shepherd-service): Rename to…
(guix-shepherd-services): … this.   Add the ‘guix-ownership’ service.
Change ‘guix-daemon’ service to depend on it; when unprivileged,
prefix ‘daemon-command’ by ‘run-with-writable-store’ and
omit ‘--build-users-group’; adjust socket activation endpoints.
(guix-accounts): When unprivileged, create the “guix-daemon” user and
group.
(guix-service-type)[extensions]: Adjust to name change.
* gnu/tests/base.scm (run-guix-daemon-test): Add ‘name’ parameter.
(%test-guix-daemon): Adjust accordingly.
(%test-guix-daemon-unprivileged): New test.
* doc/guix.texi (Base Services): Document ‘privileged?’.

Change-Id: I28a9a22e617416c551dccb24e43a253b544ba163
---
 doc/guix.texi         |  30 +++++++
 gnu/services/base.scm | 187 ++++++++++++++++++++++++++++++++++++++----
 gnu/tests/base.scm    |  47 +++++++++--
 3 files changed, 244 insertions(+), 20 deletions(-)
  

Patch

diff --git a/doc/guix.texi b/doc/guix.texi
index 5af41830ca..f58688f57a 100644
--- a/doc/guix.texi
+++ b/doc/guix.texi
@@ -20046,6 +20046,36 @@  Base Services
 The Guix package to use.  @xref{Customizing the System-Wide Guix} to
 learn how to provide a package with a pre-configured set of channels.
 
+@cindex unprivileged @command{guix-daemon}
+@cindex rootless @command{guix-daemon}
+@item @code{privileged?} (default: @code{#t})
+Whether to run @command{guix-daemon} as root.
+
+When true, @command{guix-daemon} runs with root privileges and build
+processes run under unprivileged user accounts as specified by
+@code{build-group} and @code{build-accounts} (see below); when false,
+@command{guix-daemon} run as the @code{guix-daemon} user, which is
+unprivileged, and so do build processes.  The unprivileged or
+``rootless'' mode can reduce the impact of some classes of
+vulnerabilities that could affect the daemon.
+
+The default is currently @code{#t} (@command{guix-daemon} runs with root
+privileges) but may eventually be changed to @code{#f}.
+
+@quotation Warning
+When changing this option, @file{/gnu/store}, @file{/var/guix}, and
+@file{/etc/guix} have their ownership automatically changed by the
+@code{guix-ownership} service to either the @code{guix-daemon} user or
+the @code{root} user.
+
+This can take a while, especially if @file{/gnu/store} is big; it cannot
+be interrupted and @command{guix-daemon} cannot be used until it has
+completed.
+@end quotation
+
+@xref{Build Environment Setup}, for more information on the two ways to
+run @command{guix-daemon}.
+
 @item @code{build-group} (default: @code{"guixbuild"})
 Name of the group for build user accounts.
 
diff --git a/gnu/services/base.scm b/gnu/services/base.scm
index 9a9dfdb304..8f66f54e74 100644
--- a/gnu/services/base.scm
+++ b/gnu/services/base.scm
@@ -1917,6 +1917,100 @@  (define (guix-machines-files-installation machines)
                                    #$machines))
                  machines-file))))
 
+(define (run-with-writable-store)
+  "Return a wrapper that runs the given command under the specified UID and
+GID in a context where the store is writable, even if it was bind-mounted
+read-only via %IMMUTABLE-STORE (this wrapper must run as root)."
+  (program-file "run-with-writable-store"
+                (with-imported-modules (source-module-closure
+                                        '((guix build syscalls)))
+                  #~(begin
+                      (use-modules (guix build syscalls)
+                                   (ice-9 match))
+
+                      (define (ensure-writable-store store)
+                        ;; Create a new mount namespace and remount STORE with
+                        ;; write permissions if it's read-only.
+                        (unshare CLONE_NEWNS)
+                        (let ((fs (statfs store)))
+                          (unless (zero? (logand (file-system-mount-flags fs)
+                                                 ST_RDONLY))
+                            (mount store store "none"
+                                   (logior MS_BIND MS_REMOUNT)))))
+
+                      (match (command-line)
+                        ((_ user group command args ...)
+                         (ensure-writable-store #$(%store-prefix))
+                         (let ((uid (or (string->number user)
+                                        (passwd:uid (getpwnam user))))
+                               (gid (or (string->number group)
+                                        (group:gid (getgrnam group)))))
+                           (setgroups #())
+                           (setgid gid)
+                           (setuid uid)
+                           (apply execl command command args))))))))
+
+(define (guix-ownership-change-program)
+  "Return a program that changes ownership of the store and other data files
+of Guix to the given UID and GID."
+  (program-file "validate-guix-ownership"
+                (with-imported-modules (source-module-closure
+                                        '((guix build utils)))
+                  #~(begin
+                      (use-modules (guix build utils)
+                                   (ice-9 ftw)
+                                   (ice-9 match))
+
+                      (define (lchown file uid gid)
+                        (let ((parent (open (dirname file) O_DIRECTORY)))
+                          (chown-at parent (basename file) uid gid
+                                    AT_SYMLINK_NOFOLLOW)
+                          (close-port parent)))
+
+                      (define (change-ownership directory uid gid)
+                        ;; chown -R UID:GID DIRECTORY
+                        (file-system-fold (const #t)                 ;enter?
+                                          (lambda (file stat result) ;leaf
+                                            (if (eq? 'symlink (stat:type stat))
+                                                (lchown file uid gid)
+                                                (chown file uid gid)))
+                                          (const #t) ;down
+                                          (lambda (directory stat result) ;up
+                                            (chown directory uid gid))
+                                          (const #t) ;skip
+                                          (lambda (file stat errno result)
+                                            (format (current-error-port) "i/o error: ~a: ~a~%"
+                                                    file (strerror errno))
+                                            #f)
+                                          #t      ;seed
+                                          directory
+                                          lstat))
+
+                      (define (claim-data-ownership uid gid)
+                        (format #t "Changing file ownership for /gnu/store \
+and data directories to ~a:~a...~%"
+                                uid gid)
+                        (change-ownership #$(%store-prefix) uid gid)
+                        (let ((excluded '("." ".." "profiles" "userpool")))
+                          (for-each (lambda (directory)
+                                      (change-ownership (in-vicinity "/var/guix" directory)
+                                                        uid gid))
+                                    (scandir "/var/guix"
+                                             (lambda (file)
+                                               (not (member file
+                                                            excluded))))))
+                        (chown "/var/guix" uid gid)
+                        (change-ownership "/etc/guix" uid gid)
+                        (mkdir-p "/var/log/guix")
+                        (change-ownership "/var/log/guix" uid gid))
+
+                      (match (command-line)
+                        ((_ (= string->number (? integer? uid))
+                            (= string->number (? integer? gid)))
+                         (setlocale LC_ALL "C.UTF-8") ;for file name decoding
+                         (setvbuf (current-output-port) 'line)
+                         (claim-data-ownership uid gid)))))))
+
 (define-record-type* <guix-configuration>
   guix-configuration make-guix-configuration
   guix-configuration?
@@ -1958,6 +2052,8 @@  (define-record-type* <guix-configuration>
                     (default #f))
   (tmpdir           guix-tmpdir                   ;string | #f
                     (default #f))
+  (privileged?      guix-configuration-privileged?
+                    (default #t))
   (build-machines   guix-configuration-build-machines ;list of gexps | '()
                     (default '()))
   (environment      guix-configuration-environment  ;list of strings
@@ -2020,7 +2116,7 @@  (define shepherd-discover-action
                     (environ environment)
                     #t)))))
 
-(define (guix-shepherd-service config)
+(define (guix-shepherd-services config)
   "Return a <shepherd-service> for the Guix daemon service with CONFIG."
   (define locales
     (let-system (system target)
@@ -2029,16 +2125,57 @@  (define (guix-shepherd-service config)
           glibc-utf8-locales)))
 
   (match-record config <guix-configuration>
-    (guix build-group build-accounts chroot? authorize-key? authorized-keys
+    (guix privileged?
+          build-group build-accounts chroot? authorize-key? authorized-keys
           use-substitutes? substitute-urls max-silent-time timeout
           log-compression discover? extra-options log-file
           http-proxy tmpdir chroot-directories environment
           socket-directory-permissions socket-directory-group
           socket-directory-user)
     (list (shepherd-service
+           (provision '(guix-ownership))
+           (requirement '(user-processes user-homes))
+           (one-shot? #t)
+           (start #~(lambda ()
+                      (let* ((store #$(%store-prefix))
+                             (stat (lstat store))
+                             (privileged? #$(guix-configuration-privileged?
+                                             config))
+                             (change-ownership #$(guix-ownership-change-program))
+                             (with-writable-store #$(run-with-writable-store)))
+                        ;; Check whether we're switching from privileged to
+                        ;; unprivileged guix-daemon, or vice versa, and adjust
+                        ;; file ownership accordingly.  Spawn a child process
+                        ;; if and only if something needs to be changed.
+                        ;;
+                        ;; Note: This service remains in 'starting' state for
+                        ;; as long as CHANGE-OWNERSHIP is running.  That way,
+                        ;; 'guix-daemon' starts only once we're done.
+                        (cond ((and (not privileged?)
+                                    (or (zero? (stat:uid stat))
+                                        (zero? (stat:gid stat))))
+                               (let ((user (getpwnam "guix-daemon")))
+                                 (format #t "Changing to unprivileged guix-daemon.~%")
+                                 (zero?
+                                  (system* with-writable-store "0" "0"
+                                           change-ownership
+                                           (number->string (passwd:uid user))
+                                           (number->string (passwd:gid user))))))
+                              ((and privileged?
+                                    (and (not (zero? (stat:uid stat)))
+                                         (not (zero? (stat:gid stat)))))
+                               (format #t "Changing to privileged guix-daemon.~%")
+                               (zero? (system* with-writable-store "0" "0"
+                                               change-ownership "0" "0")))
+                              (else #t)))))
+           (documentation "Ensure that the store and other data files used by
+guix-daemon have the right ownership."))
+
+          (shepherd-service
            (documentation "Run the Guix daemon.")
            (provision '(guix-daemon))
            (requirement `(user-processes
+                          guix-ownership
                           ,@(if discover? '(avahi-daemon) '())))
            (actions (list shepherd-set-http-proxy-action
                           shepherd-discover-action))
@@ -2062,8 +2199,15 @@  (define (guix-shepherd-service config)
                     (or (getenv "discover") #$discover?))
 
                   (define daemon-command
-                    (cons* #$(file-append guix "/bin/guix-daemon")
-                           "--build-users-group" #$build-group
+                    (cons* #$@(if privileged?
+                                  #~()
+                                  #~(#$(run-with-writable-store)
+                                     "guix-daemon" "guix-daemon"))
+
+                           #$(file-append guix "/bin/guix-daemon")
+                           #$@(if privileged?
+                                  #~("--build-users-group" #$build-group)
+                                  #~())
                            "--max-silent-time"
                            #$(number->string max-silent-time)
                            "--timeout" #$(number->string timeout)
@@ -2144,9 +2288,11 @@  (define (guix-shepherd-service config)
                                      "/var/guix/daemon-socket/socket")
                                     #:name "socket"
                                     #:socket-owner
-                                    (or #$socket-directory-user 0)
+                                    (or #$socket-directory-user
+                                        #$(if privileged? 0 "guix-daemon"))
                                     #:socket-group
-                                    (or #$socket-directory-group 0)
+                                    (or #$socket-directory-group
+                                        #$(if privileged? 0 "guix-daemon"))
                                     #:socket-directory-permissions
                                     #$socket-directory-permissions)))
                        ((make-systemd-constructor daemon-command
@@ -2161,15 +2307,26 @@  (define (guix-shepherd-service config)
 
 (define (guix-accounts config)
   "Return the user accounts and user groups for CONFIG."
-  (cons (user-group
-         (name (guix-configuration-build-group config))
-         (system? #t)
+  (if (guix-configuration-privileged? config)
+      (cons (user-group
+             (name (guix-configuration-build-group config))
+             (system? #t)
 
-         ;; Use a fixed GID so that we can create the store with the right
-         ;; owner.
-         (id 30000))
-        (guix-build-accounts (guix-configuration-build-accounts config)
-                             #:group (guix-configuration-build-group config))))
+             ;; Use a fixed GID so that we can create the store with the right
+             ;; owner.
+             (id 30000))
+            (guix-build-accounts (guix-configuration-build-accounts config)
+                                 #:group (guix-configuration-build-group
+                                          config)))
+      (list (user-group (name "guix-daemon") (system? #t))
+            (user-account
+             (name "guix-daemon")
+             (group "guix-daemon")
+             (system? #t)
+             (supplementary-groups '("kvm"))
+             (comment "Guix Daemon User")
+             (home-directory "/var/empty")
+             (shell (file-append shadow "/sbin/nologin"))))))
 
 (define (guix-activation config)
   "Return the activation gexp for CONFIG."
@@ -2227,7 +2384,7 @@  (define guix-service-type
   (service-type
    (name 'guix)
    (extensions
-    (list (service-extension shepherd-root-service-type guix-shepherd-service)
+    (list (service-extension shepherd-root-service-type guix-shepherd-services)
           (service-extension account-service-type guix-accounts)
           (service-extension activation-service-type guix-activation)
           (service-extension profile-service-type
diff --git a/gnu/tests/base.scm b/gnu/tests/base.scm
index 83e047f7e6..12d4e70ee5 100644
--- a/gnu/tests/base.scm
+++ b/gnu/tests/base.scm
@@ -1,5 +1,5 @@ 
 ;;; GNU Guix --- Functional package management for GNU
-;;; Copyright © 2016-2020, 2022, 2024 Ludovic Courtès <ludo@gnu.org>
+;;; Copyright © 2016-2020, 2022, 2024-2025 Ludovic Courtès <ludo@gnu.org>
 ;;; Copyright © 2018 Clément Lassieur <clement@lassieur.org>
 ;;; Copyright © 2022 Maxim Cournoyer <maxim.cournoyer@gmail.com>
 ;;; Copyright © 2022 Marius Bakke <marius@gnu.org>
@@ -63,7 +63,8 @@  (define-module (gnu tests base)
 
             %hello-dependencies-manifest
             guix-daemon-test-cases
-            %test-guix-daemon))
+            %test-guix-daemon
+            %test-guix-daemon-unprivileged))
 
 (define %simple-os
   (simple-operating-system))
@@ -1121,7 +1122,7 @@  (define (guix-daemon-test-cases marionette)
                               (system-error-errno args)))
                          #$marionette))))
 
-(define (run-guix-daemon-test os)
+(define (run-guix-daemon-test os name)
   (define test-image
     (image (operating-system os)
            (format 'compressed-qcow2)
@@ -1161,6 +1162,12 @@  (define (run-guix-daemon-test os)
             ;; Wait for 'guix-daemon' to be up.
             (marionette-eval '(begin
                                 (use-modules (gnu services herd))
+                                (start-service 'guix-daemon)
+
+                                ;; XXX: Do it a second time to work around
+                                ;; <https://issues.guix.gnu.org/77274> and its
+                                ;; effect on the 'guix-ownership' service.
+                                ;; TODO: Remove when Shepherd 1.0.4 is out.
                                 (start-service 'guix-daemon))
                              marionette))
 
@@ -1168,7 +1175,7 @@  (define (run-guix-daemon-test os)
 
           (test-end))))
 
-  (gexp->derivation "guix-daemon-test" test))
+  (gexp->derivation name test))
 
 (define %test-guix-daemon
   (system-test
@@ -1190,4 +1197,34 @@  (define %test-guix-daemon
                               %base-user-accounts)))
                #:imported-modules '((gnu services herd)
                                     (guix combinators)))))
-      (run-guix-daemon-test os)))))
+      (run-guix-daemon-test os "guix-daemon-test")))))
+
+(define %test-guix-daemon-unprivileged
+  (system-test
+   (name "guix-daemon-unprivileged")
+   (description
+    "Test 'guix-daemon' behavior on a multi-user system, where 'guix-daemon'
+runs unprivileged.")
+   (value
+    (let ((os (marionette-operating-system
+               (let ((base (operating-system-with-gc-roots
+                            %daemon-os
+                            (list (profile
+                                   (name "hello-build-dependencies")
+                                   (content %hello-dependencies-manifest))))))
+                 (operating-system
+                   (inherit base)
+                   (kernel-arguments '("console=ttyS0"))
+                   (users (cons (user-account
+                                 (name "user")
+                                 (group "users"))
+                                %base-user-accounts))
+                   (services
+                    (modify-services (operating-system-user-services base)
+                      (guix-service-type
+                       config => (guix-configuration
+                                  (inherit config)
+                                  (privileged? #f)))))))
+               #:imported-modules '((gnu services herd)
+                                    (guix combinators)))))
+      (run-guix-daemon-test os "guix-daemon-unprivileged-test")))))