guix/gnu/build/accounts.scm

587 lines
22 KiB
Scheme
Raw Normal View History

;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2019, 2021, 2023 Ludovic Courtès <ludo@gnu.org>
;;;
;;; This file is part of GNU Guix.
;;;
;;; GNU Guix is free software; you can redistribute it and/or modify it
;;; under the terms of the GNU General Public License as published by
;;; the Free Software Foundation; either version 3 of the License, or (at
;;; your option) any later version.
;;;
;;; GNU Guix is distributed in the hope that it will be useful, but
;;; WITHOUT ANY WARRANTY; without even the implied warranty of
;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;; GNU General Public License for more details.
;;;
;;; You should have received a copy of the GNU General Public License
;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
(define-module (gnu build accounts)
#:use-module (guix records)
#:use-module (guix combinators)
#:use-module (gnu system accounts)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-11)
#:use-module (srfi srfi-19)
#:use-module (srfi srfi-26)
#:use-module (ice-9 match)
#:use-module (ice-9 vlist)
#:use-module (ice-9 rdelim)
#:export (password-entry
password-entry?
password-entry-name
password-entry-uid
password-entry-gid
password-entry-real-name
password-entry-directory
password-entry-shell
shadow-entry
shadow-entry?
shadow-entry-name
shadow-entry-minimum-change-period
shadow-entry-maximum-change-period
shadow-entry-change-warning-time
shadow-entry-maximum-inactivity
shadow-entry-expiration
group-entry
group-entry?
group-entry-name
group-entry-gid
group-entry-members
%password-lock-file
write-group
write-passwd
write-shadow
read-group
read-passwd
read-shadow
%id-min
%id-max
%system-id-min
%system-id-max
user+group-databases))
;;; Commentary:
;;;
;;; This modules provides functionality equivalent to the C library's
;;; <shadow.h>, <pwd.h>, and <grp.h> routines, as well as a subset of the
;;; functionality of the Shadow command-line tools. It can parse and write
;;; /etc/passwd, /etc/shadow, and /etc/group. It can also take care of UID
;;; and GID allocation in a way similar to what 'useradd' does.
;;;
;;; The benefit is twofold: less code is involved, and the ID allocation
;;; strategy and state preservation is made explicit.
;;;
;;; Code:
;;;
;;; Machinery to define user and group databases.
;;;
(define-syntax serialize-field
(syntax-rules (serialization)
((_ entry (field get (serialization ->string string->) _ ...))
(->string (get entry)))
((_ entry (field get _ ...))
(get entry))))
(define-syntax deserialize-field
(syntax-rules (serialization)
((_ str (field get (serialization ->string string->) _ ...))
(string-> str))
((_ str (field get _ ...))
str)))
(define-syntax let/fields
(syntax-rules ()
((_ (((name get attributes ...) rest ...) lst) body ...)
(let ((l lst))
(let ((name (deserialize-field (car l)
(name get attributes ...))))
(let/fields ((rest ...) (cdr l)) body ...))))
((_ (() lst) body ...)
(begin body ...))))
(define-syntax define-database-entry
(syntax-rules (serialization)
"Define a record data type, as per 'define-record-type*', with additional
information on how to serialize and deserialize the whole database as well as
each field."
((_ <record> record make-record record?
(serialization separator entry->string string->entry)
fields ...)
(let-syntax ((field-name
(syntax-rules ()
((_ (name _ (... ...))) name))))
(define-record-type* <record> record make-record
record?
fields ...)
(define (entry->string entry)
(string-join (list (serialize-field entry fields) ...)
(string separator)))
(define (string->entry str)
(let/fields ((fields ...) (string-split str #\:))
(make-record (field-name fields) ...)))))))
(define number->string*
(match-lambda
((? number? number) (number->string number))
(_ "")))
(define (false-if-string=? false-string)
(lambda (str)
(if (string=? str false-string)
#f
str)))
(define (string-if-false str)
(lambda (obj)
(if (not obj) str obj)))
(define (comma-separated->list str)
(string-tokenize str (char-set-complement (char-set #\,))))
(define (list->comma-separated lst)
(string-join lst ","))
;;;
;;; Database definitions.
;;;
(define-database-entry <password-entry> ;<pwd.h>
password-entry make-password-entry
password-entry?
(serialization #\: password-entry->string string->password-entry)
(name password-entry-name)
(password password-entry-password
(serialization (const "x") (const #f))
(default "x"))
(uid password-entry-uid
(serialization number->string string->number))
(gid password-entry-gid
(serialization number->string string->number))
(real-name password-entry-real-name
(default ""))
(directory password-entry-directory)
(shell password-entry-shell
(default "/bin/sh")))
(define-database-entry <shadow-entry> ;<shadow.h>
shadow-entry make-shadow-entry
shadow-entry?
(serialization #\: shadow-entry->string string->shadow-entry)
(name shadow-entry-name) ;string
(password shadow-entry-password ;string | #f
(serialization (string-if-false "!")
(false-if-string=? "!"))
(default #f))
(last-change shadow-entry-last-change ;days since 1970-01-01
(serialization number->string* string->number)
(default 0))
(minimum-change-period shadow-entry-minimum-change-period
(serialization number->string* string->number)
(default #f)) ;days | #f
(maximum-change-period shadow-entry-maximum-change-period
(serialization number->string* string->number)
(default #f)) ;days | #f
(change-warning-time shadow-entry-change-warning-time
(serialization number->string* string->number)
(default #f)) ;days | #f
(maximum-inactivity shadow-entry-maximum-inactivity
(serialization number->string* string->number)
(default #f)) ;days | #f
(expiration shadow-entry-expiration
(serialization number->string* string->number)
(default #f)) ;days since 1970-01-01 | #f
(flags shadow-entry-flags ;"reserved"
(serialization number->string* string->number)
(default #f)))
(define-database-entry <group-entry> ;<grp.h>
group-entry make-group-entry
group-entry?
(serialization #\: group-entry->string string->group-entry)
(name group-entry-name)
(password group-entry-password
(serialization (string-if-false "x")
(false-if-string=? "x"))
(default #f))
(gid group-entry-gid
(serialization number->string string->number))
(members group-entry-members
(serialization list->comma-separated comma-separated->list)
(default '())))
(define %password-lock-file
;; The password database lock file used by libc's 'lckpwdf'. Users should
;; grab this lock with 'with-file-lock' when they access the databases.
"/etc/.pwd.lock")
(define (database-writer file mode entry->string)
(lambda* (entries #:optional (file-or-port file))
"Write ENTRIES to FILE-OR-PORT. When FILE-OR-PORT is a file name, write
to it atomically and set the appropriate permissions."
(define (write-entries port)
(for-each (lambda (entry)
(display (entry->string entry) port)
(newline port))
(delete-duplicates entries)))
(if (port? file-or-port)
(write-entries file-or-port)
(let* ((template (string-append file-or-port ".XXXXXX"))
(port (mkstemp! template)))
(dynamic-wind
(const #t)
(lambda ()
(chmod port mode)
(write-entries port)
(fsync port)
(close-port port)
(rename-file template file-or-port))
(lambda ()
(unless (port-closed? port)
(close-port port))
(when (file-exists? template)
(delete-file template))))))))
(define write-passwd
(database-writer "/etc/passwd" #o644 password-entry->string))
(define write-shadow
(database-writer "/etc/shadow" #o600 shadow-entry->string))
(define write-group
(database-writer "/etc/group" #o644 group-entry->string))
(define (database-reader file string->entry)
(lambda* (#:optional (file-or-port file))
(define (read-entries port)
(let loop ((entries '()))
(match (read-line port)
((? eof-object?)
(reverse entries))
(line
(loop (cons (string->entry line) entries))))))
(if (port? file-or-port)
(read-entries file-or-port)
(call-with-input-file file-or-port
read-entries))))
(define read-passwd
(database-reader "/etc/passwd" string->password-entry))
(define read-shadow
(database-reader "/etc/shadow" string->shadow-entry))
(define read-group
(database-reader "/etc/group" string->group-entry))
;;;
;;; Building databases.
;;;
(define-record-type* <allocation>
allocation make-allocation
allocation?
(ids allocation-ids (default vlist-null))
(next-id allocation-next-id (default %id-min))
(next-system-id allocation-next-system-id (default %system-id-max)))
;; Trick to avoid name clashes...
(define-syntax %allocation (identifier-syntax allocation))
;; Minimum and maximum UIDs and GIDs (from find_new_uid.c and find_new_gid.c
;; in Shadow.)
(define %id-min 1000)
(define %id-max 60000)
(define %system-id-min 100)
(define %system-id-max 999)
(define (system-id? id)
(and (> id %system-id-min)
(<= id %system-id-max)))
(define (user-id? id)
(and (>= id %id-min)
(< id %id-max)))
(define* (allocate-id assignment #:key system?)
"Return two values: a newly allocated ID, and an updated <allocation> record
based on ASSIGNMENT. If SYSTEM? is true, return a system ID."
(define next
;; Return the next available ID, looping if necessary.
(if system?
(lambda (id)
(let ((next-id (- id 1)))
(if (< next-id %system-id-min)
%system-id-max
next-id)))
(lambda (id)
(let ((next-id (+ id 1)))
(if (>= next-id %id-max)
%id-min
next-id)))))
(let loop ((id (if system?
(allocation-next-system-id assignment)
(allocation-next-id assignment))))
(if (vhash-assv id (allocation-ids assignment))
(loop (next id))
(let ((taken (vhash-consv id #t (allocation-ids assignment))))
(values (if system?
(allocation (inherit assignment)
(next-system-id (next id))
(ids taken))
(allocation (inherit assignment)
(next-id (next id))
(ids taken)))
id)))))
(define* (reserve-ids allocation ids #:key (skip? #t))
"Mark the numbers listed in IDS as reserved in ALLOCATION. When SKIP? is
true, start allocation after the highest (or lowest, depending on whether it's
a system ID allocation) number among IDS."
(%allocation
(inherit allocation)
(next-id (if skip?
(+ (reduce max
(- (allocation-next-id allocation) 1)
(filter user-id? ids))
1)
(allocation-next-id allocation)))
(next-system-id
(if skip?
(- (reduce min
(+ 1 (allocation-next-system-id allocation))
(filter system-id? ids))
1)
(allocation-next-system-id allocation)))
(ids (fold (cut vhash-consv <> #t <>)
(allocation-ids allocation)
ids))))
(define (allocated? allocation id)
"Return true if ID is already allocated as part of ALLOCATION."
(->bool (vhash-assv id (allocation-ids allocation))))
(define (lookup-procedure lst key)
"Return a lookup procedure for the elements of LST, calling KEY to obtain
the key of each element."
(let ((table (fold (lambda (obj table)
(vhash-cons (key obj) obj table))
vlist-null
lst)))
(lambda (key)
(match (vhash-assoc key table)
(#f #f)
((_ . value) value)))))
(define* (allocate-groups groups members
#:optional (current-groups '()))
"Return a list of group entries for GROUPS, a list of <user-group>. Members
for each group are taken from MEMBERS, a vhash that maps group names to member
names. GIDs and passwords found in CURRENT-GROUPS, a list of group entries,
are reused."
(define gids
;; Mark all the currently-used GIDs and the explicitly requested GIDs as
;; reserved.
(reserve-ids (reserve-ids (allocation)
(map group-entry-gid current-groups))
(filter-map user-group-id groups)
#:skip? #f))
(define previous-entry
(lookup-procedure current-groups group-entry-name))
(reverse
(fold2 (lambda (group result allocation)
(let ((name (user-group-name group))
(password (user-group-password group))
(requested-id (user-group-id group))
(system? (user-group-system? group)))
(let*-values (((previous)
(previous-entry name))
((allocation id)
(cond
((number? requested-id)
(values (reserve-ids allocation
(list requested-id))
requested-id))
(previous
(values allocation
(group-entry-gid previous)))
(else
(allocate-id allocation
#:system? system?)))))
(values (cons (group-entry
(name name)
(password
(if previous
(group-entry-password previous)
password))
(gid id)
(members (vhash-fold* cons '() name members)))
result)
allocation))))
'()
gids
groups)))
(define* (allocate-passwd users groups #:optional (current-passwd '()))
"Return a list of password entries for USERS, a list of <user-account>.
Take GIDs from GROUPS, a list of group entries. Reuse UIDs from
CURRENT-PASSWD, a list of password entries, when possible; otherwise allocate
new UIDs."
(define uids
(reserve-ids (reserve-ids (allocation)
(map password-entry-uid current-passwd))
(filter-map user-account-uid users)
#:skip? #f))
(define previous-entry
(lookup-procedure current-passwd password-entry-name))
(define (group-id name)
(or (any (lambda (entry)
(and (string=? (group-entry-name entry) name)
(group-entry-gid entry)))
groups)
(error "group not found" name)))
(reverse
(fold2 (lambda (user result allocation)
(let ((name (user-account-name user))
(requested-id (user-account-uid user))
(group (user-account-group user))
(real-name (user-account-comment user))
(directory (user-account-home-directory user))
(shell (user-account-shell user))
(system? (user-account-system? user)))
(let*-values (((previous)
(previous-entry name))
((allocation id)
(cond
((number? requested-id)
(values (reserve-ids allocation
(list requested-id))
requested-id))
(previous
(values allocation
(password-entry-uid previous)))
(else
(allocate-id allocation
#:system? system?)))))
(values (cons (password-entry
(name name)
(uid id)
(directory directory)
(gid (if (number? group) group (group-id group)))
;; Users might change their name to something
;; other than what the sysadmin chose, with
;; 'chfn'. Thus consider it "stateful".
(real-name (if (and previous (not system?))
(password-entry-real-name previous)
real-name))
;; Do not reuse the shell of PREVIOUS since (1)
;; that could lead to confusion, and (2) the
;; shell might have been GC'd. See
;; <https://lists.gnu.org/archive/html/guix-devel/2019-04/msg00478.html>.
(shell shell))
result)
allocation))))
'()
uids
users)))
(define* (days-since-epoch #:optional (current-time current-time))
"Return the number of days elapsed since the 1st of January, 1970."
(let* ((now (current-time time-utc))
(epoch (make-time time-utc 0 0))
(diff (time-difference now epoch)))
(quotient (time-second diff) (* 24 3600))))
(define* (passwd->shadow users passwd #:optional (current-shadow '())
#:key (current-time current-time))
"Return a list of shadow entries for the password entries listed in PASSWD.
Reuse shadow entries from CURRENT-SHADOW when they exist, and take the initial
password from USERS."
(define previous-entry
(lookup-procedure current-shadow shadow-entry-name))
(define now
;; On machines without a real-time clock (typically Arm SBCs), the system
;; clock may be at 1970-01-01 while booting, which would lead us to define
;; NOW as zero.
;;
;; However, the 'isexpired' function in Shadow interprets the combination
;; uninitialized password + last-change = 0 as "The password has expired,
;; it must be changed", which prevents logins altogether. To avoid that,
;; never set 'last-change' to zero.
(max (days-since-epoch current-time) 1))
(map (lambda (user passwd)
(or (previous-entry (password-entry-name passwd))
(shadow-entry (name (password-entry-name passwd))
(password (user-account-password user))
(last-change now))))
users passwd))
(define (empty-if-not-found thunk)
"Call THUNK and return the empty list if that throws to ENOENT."
(catch 'system-error
thunk
(lambda args
(if (= ENOENT (system-error-errno args))
'()
(apply throw args)))))
(define* (user+group-databases users groups
#:key
(current-passwd
(empty-if-not-found read-passwd))
(current-groups
(empty-if-not-found read-group))
(current-shadow
(empty-if-not-found read-shadow))
(current-time current-time))
"Return three values: the list of group entries, the list of password
entries, and the list of shadow entries corresponding to USERS and GROUPS.
Preserve stateful bits from CURRENT-PASSWD, CURRENT-GROUPS, and
CURRENT-SHADOW: UIDs, GIDs, passwords, user shells, etc."
(define members
;; Map group name to user names.
(fold (lambda (user members)
(fold (cute vhash-cons <> (user-account-name user) <>)
members
(user-account-supplementary-groups user)))
vlist-null
users))
(define group-entries
(allocate-groups groups members current-groups))
(define passwd-entries
(allocate-passwd users group-entries current-passwd))
(define shadow-entries
(passwd->shadow users passwd-entries current-shadow
#:current-time current-time))
(values group-entries passwd-entries shadow-entries))