summaryrefslogtreecommitdiff
path: root/lisp/zfs-cli.el
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-09-04 10:49:20 +0200
committercashmere <cashmere@autistici.org>2026-09-04 10:49:20 +0200
commit317c055fc3ec29adb1acd5722f7e7fe8483b336c (patch)
tree902acdcb4721d3d6b54c1e8e50acde62bfd43bed /lisp/zfs-cli.el
parent657a2cae67992e85f22b8390e7ea8ee053076bdc (diff)
downloademacs-317c055fc3ec29adb1acd5722f7e7fe8483b336c.tar.gz
relic: 2026-09-04T10:49:24+02:00
Diffstat (limited to 'lisp/zfs-cli.el')
-rw-r--r--lisp/zfs-cli.el211
1 files changed, 190 insertions, 21 deletions
diff --git a/lisp/zfs-cli.el b/lisp/zfs-cli.el
index 0d7b747..c72a114 100644
--- a/lisp/zfs-cli.el
+++ b/lisp/zfs-cli.el
@@ -17,6 +17,11 @@ Example: ((:name \"vps1\" :ssh \"cashmere@1.2.3.4\"))"
:type '(repeat string)
:group 'zfs)
+(defcustom zfs-authentication-command nil
+ "Command used for interactive local privilege authentication."
+ :type '(choice (const :tag "Disabled" nil) (repeat string))
+ :group 'zfs)
+
(defconst zfs-cli--permission-error-re "permission denied\\|Insufficient privileges\\|delegated permission")
(defconst zfs-cli-dataset-properties
@@ -36,10 +41,9 @@ Example: ((:name \"vps1\" :ssh \"cashmere@1.2.3.4\"))"
(mapconcat #'shell-quote-argument command " "))
command)))
-(defun zfs-cli--call (host program args sudo &optional input)
+(defun zfs-cli--call-argv (argv &optional input)
(let ((stderr-file (make-temp-file "zfs-cli-"))
- (input-file (and input (make-temp-file "zfs-cli-in-")))
- (argv (zfs-cli--argv host program args sudo)))
+ (input-file (and input (make-temp-file "zfs-cli-in-"))))
(unwind-protect
(progn
(when input-file
@@ -55,6 +59,40 @@ Example: ((:name \"vps1\" :ssh \"cashmere@1.2.3.4\"))"
(delete-file stderr-file)
(when input-file (delete-file input-file)))))
+(defun zfs-cli--call (host program args sudo &optional input)
+ (zfs-cli--call-argv (zfs-cli--argv host program args sudo) input))
+
+(defun zfs-cli--call-argv-async (argv input callback)
+ "Run ARGV asynchronously, send INPUT to stdin, and call CALLBACK with status, stdout, and stderr."
+ (let ((stdout (generate-new-buffer " *zfs-cli-async-out*"))
+ (stderr (generate-new-buffer " *zfs-cli-async-err*")))
+ (condition-case err
+ (let ((process
+ (make-process
+ :name "zfs-cli-authenticated"
+ :buffer stdout
+ :stderr stderr
+ :command argv
+ :connection-type 'pipe
+ :noquery t
+ :sentinel
+ (lambda (process _event)
+ (when (memq (process-status process) '(exit signal))
+ (let ((status (process-exit-status process))
+ (out (with-current-buffer stdout (buffer-string)))
+ (err (string-trim (with-current-buffer stderr (buffer-string)))))
+ (kill-buffer stdout)
+ (kill-buffer stderr)
+ (funcall callback status out err)))))))
+ (when input
+ (process-send-string process input)
+ (process-send-eof process))
+ process)
+ (error
+ (kill-buffer stdout)
+ (kill-buffer stderr)
+ (signal (car err) (cdr err))))))
+
(defun zfs-cli-run (host program args &optional input)
(let ((result (zfs-cli--call host program args nil input)))
(when (and (/= 0 (car result))
@@ -269,35 +307,66 @@ Example: ((:name \"vps1\" :ssh \"cashmere@1.2.3.4\"))"
(unless (zerop (car result))
(error "zfs-cli: %s" (cdr result)))))
-(defun zfs-cli--parse-block-device (device)
- (list :name (alist-get 'name device)
- :path (concat "/dev/" (alist-get 'name device))
- :size (alist-get 'size device)
- :fstype (alist-get 'fstype device)
- :model (let ((model (alist-get 'model device)))
- (and model (string-trim model)))
- :type (alist-get 'type device)
- :mounted (seq-some #'identity (alist-get 'mountpoints device))
- :children (mapcar #'zfs-cli--parse-block-device (alist-get 'children device))))
+(defun zfs-cli--parse-block-device (device &optional parent-name parent-model)
+ (let* ((name (alist-get 'name device))
+ (own-model (when-let* ((model (alist-get 'model device)))
+ (string-trim model)))
+ (model (or own-model parent-model)))
+ (list :name name
+ :path (concat "/dev/" name)
+ :size (alist-get 'size device)
+ :fstype (alist-get 'fstype device)
+ :model model
+ :type (alist-get 'type device)
+ :pttype (alist-get 'pttype device)
+ :read-only (eq (alist-get 'ro device) t)
+ :mounted (seq-some #'identity (alist-get 'mountpoints device))
+ :parent parent-name
+ :children (mapcar (lambda (child)
+ (zfs-cli--parse-block-device child name model))
+ (alist-get 'children device)))))
(defun zfs-cli-block-devices ()
- (let ((json (zfs-cli-run-json nil "lsblk" '("-J" "-b" "-o" "NAME,SIZE,FSTYPE,MODEL,TYPE,MOUNTPOINTS"))))
+ (let ((json (zfs-cli-run-json nil "lsblk" '("-J" "-b" "-o" "NAME,SIZE,FSTYPE,MODEL,TYPE,MOUNTPOINTS,PTTYPE,RO"))))
(mapcar #'zfs-cli--parse-block-device (alist-get 'blockdevices json))))
+(defun zfs-cli--flatten-device-tree (devices)
+ (mapcan (lambda (device)
+ (cons device (zfs-cli--flatten-device-tree (plist-get device :children))))
+ devices))
+
(defun zfs-cli-locked-disks ()
(seq-filter (lambda (device)
- (and (member (plist-get device :type) '("disk" "loop"))
+ (and (member (plist-get device :type) '("disk" "part" "loop"))
(equal (plist-get device :fstype) "crypto_LUKS")))
- (zfs-cli-block-devices)))
+ (zfs-cli--flatten-device-tree (zfs-cli-block-devices))))
(defun zfs-cli-disk-unlocked-p (device)
- (seq-find (lambda (child) (equal (plist-get child :type) "crypt"))
- (plist-get device :children)))
+ (or (seq-find (lambda (child) (equal (plist-get child :type) "crypt"))
+ (plist-get device :children))
+ (zfs-cli-mapper-node (concat "luks-" (plist-get device :name)))))
(defun zfs-cli-disk-mapper (device)
- (when-let* ((child (seq-find (lambda (entry) (equal (plist-get entry :type) "crypt"))
- (plist-get device :children))))
- (plist-get child :name)))
+ (if-let* ((child (seq-find (lambda (entry) (equal (plist-get entry :type) "crypt"))
+ (plist-get device :children))))
+ (plist-get child :name)
+ (let ((name (concat "luks-" (plist-get device :name))))
+ (and (zfs-cli-mapper-node name) name))))
+
+(defun zfs-cli-mapper-node (name)
+ "Return the /dev/dm-* node whose Device Mapper name is NAME."
+ (when (file-directory-p "/sys/class/block")
+ (seq-some
+ (lambda (entry)
+ (let ((name-file (expand-file-name "dm/name" entry)))
+ (when (and (file-readable-p name-file)
+ (equal (string-trim
+ (with-temp-buffer
+ (insert-file-contents name-file)
+ (buffer-string)))
+ name))
+ (concat "/dev/" (file-name-nondirectory (directory-file-name entry))))))
+ (directory-files "/sys/class/block" t "\\`dm-[0-9]+\\'"))))
(defun zfs-cli--device-tree-mounted-p (device)
(or (plist-get device :mounted)
@@ -310,6 +379,10 @@ Example: ((:name \"vps1\" :ssh \"cashmere@1.2.3.4\"))"
(defun zfs-cli-disk-candidates ()
(seq-filter (lambda (device)
(and (equal (plist-get device :type) "disk")
+ (not (plist-get device :read-only))
+ (not (plist-get device :fstype))
+ (not (plist-get device :pttype))
+ (not (plist-get device :children))
(not (zfs-cli--device-tree-mounted-p device))
(not (zfs-cli--device-tree-active-p device))))
(zfs-cli-block-devices)))
@@ -321,12 +394,108 @@ Example: ((:name \"vps1\" :ssh \"cashmere@1.2.3.4\"))"
(match-string 1 (cdr result)))))
(defun zfs-cli-luks-open (device name passphrase)
+ (unless (executable-find "cryptsetup")
+ (error "cryptsetup is required to unlock LUKS devices"))
(let ((result (zfs-cli--call nil "cryptsetup" (list "luksOpen" device name) t
(concat passphrase "\n"))))
(unless (zerop (car result))
(error "%s" (cdr result)))))
+(defconst zfs-cli--luks-unlock-script
+ (concat
+ "cryptsetup=$1\n"
+ "zdb=$2\n"
+ "zpool=$3\n"
+ "sed=$4\n"
+ "mknod=$5\n"
+ "device=$6\n"
+ "mapper=$7\n"
+ "find_mapper() {\n"
+ " mapper_node=\n"
+ " mapper_sysfs=\n"
+ " for dm in /sys/class/block/dm-*; do\n"
+ " [ -r \"$dm/dm/name\" ] || continue\n"
+ " IFS= read -r dm_name < \"$dm/dm/name\" || continue\n"
+ " if [ \"$dm_name\" = \"$mapper\" ]; then\n"
+ " mapper_sysfs=$dm\n"
+ " if [ -b \"/dev/mapper/$mapper\" ]; then\n"
+ " mapper_node=/dev/mapper/$mapper\n"
+ " elif [ -b \"/dev/${dm##*/}\" ]; then\n"
+ " mapper_node=/dev/${dm##*/}\n"
+ " fi\n"
+ " return 0\n"
+ " fi\n"
+ " done\n"
+ "}\n"
+ "find_mapper\n"
+ "if [ -z \"$mapper_sysfs\" ]; then\n"
+ " \"$cryptsetup\" luksOpen \"$device\" \"$mapper\" || exit $?\n"
+ " find_mapper\n"
+ "fi\n"
+ "if [ -z \"$mapper_node\" ] && [ -r \"$mapper_sysfs/dev\" ]; then\n"
+ " IFS=: read -r major minor < \"$mapper_sysfs/dev\" || exit 1\n"
+ " [ -d /dev/mapper ] || { printf '/dev/mapper is unavailable\\n' >&2; exit 1; }\n"
+ " \"$mknod\" -m 0600 \"/dev/mapper/$mapper\" b \"$major\" \"$minor\" || exit $?\n"
+ " mapper_node=/dev/mapper/$mapper\n"
+ "fi\n"
+ "[ -b \"$mapper_node\" ] || { printf 'Mapper %s has no block device\\n' \"$mapper\" >&2; exit 1; }\n"
+ "pool=$(\"$zdb\" -l \"$mapper_node\" 2>/dev/null | \"$sed\" -n \"s/^[[:space:]]*name: '\\([^']*\\)'.*/\\1/p\" | \"$sed\" -n '1p')\n"
+ "if [ -n \"$pool\" ] && ! \"$zpool\" list -H -o name \"$pool\" >/dev/null 2>&1; then\n"
+ " \"$zpool\" import -N \"$pool\" || \"$zpool\" import -d \"$mapper_node\" -N \"$pool\" || exit $?\n"
+ "fi\n"
+ "printf '%s\\n' \"$pool\"\n")
+ "Root-side script for unlocking a LUKS device and importing its ZFS pool.")
+
+(defun zfs-cli--required-executable (program)
+ "Return the absolute path to PROGRAM or signal a clear error."
+ (or (executable-find program)
+ (error "%s is required for this operation" program)))
+
+(defun zfs-cli-luks-unlock-and-import (device name passphrase)
+ "Unlock DEVICE as NAME and import its ZFS pool in one authenticated process; return the imported pool name, or nil when the unlocked device has no ZFS label."
+ (unless zfs-authentication-command
+ (error "Privileged authentication is required, but `zfs-authentication-command' is disabled"))
+ (let* ((shell (zfs-cli--required-executable "sh"))
+ (cryptsetup (zfs-cli--required-executable "cryptsetup"))
+ (zdb (zfs-cli--required-executable "zdb"))
+ (zpool (zfs-cli--required-executable "zpool"))
+ (sed (zfs-cli--required-executable "sed"))
+ (mknod (zfs-cli--required-executable "mknod"))
+ (result (zfs-cli--call-argv
+ (append zfs-authentication-command
+ (list shell "-c" zfs-cli--luks-unlock-script "zfs-unlock"
+ cryptsetup zdb zpool sed mknod device name))
+ (concat passphrase "\n"))))
+ (unless (zerop (car result))
+ (error "%s" (cdr result)))
+ (let ((pool (string-trim (cdr result))))
+ (unless (string-empty-p pool) pool))))
+
+(defun zfs-cli-luks-unlock-and-import-async (device name passphrase callback)
+ "Asynchronously unlock DEVICE as NAME, import its pool, and invoke CALLBACK with the exit status, imported pool name, and error text."
+ (unless zfs-authentication-command
+ (error "Privileged authentication is required, but `zfs-authentication-command' is disabled"))
+ (let ((shell (zfs-cli--required-executable "sh"))
+ (cryptsetup (zfs-cli--required-executable "cryptsetup"))
+ (zdb (zfs-cli--required-executable "zdb"))
+ (zpool (zfs-cli--required-executable "zpool"))
+ (sed (zfs-cli--required-executable "sed"))
+ (mknod (zfs-cli--required-executable "mknod")))
+ (zfs-cli--call-argv-async
+ (append zfs-authentication-command
+ (list shell "-c" zfs-cli--luks-unlock-script "zfs-unlock"
+ cryptsetup zdb zpool sed mknod device name))
+ (concat (or passphrase "") "\n")
+ (lambda (status out err)
+ (funcall callback status
+ (and (zerop status)
+ (let ((pool (string-trim out)))
+ (unless (string-empty-p pool) pool)))
+ err)))))
+
(defun zfs-cli-luks-close (name)
+ (unless (executable-find "cryptsetup")
+ (error "cryptsetup is required to close LUKS devices"))
(let ((result (zfs-cli--call nil "cryptsetup" (list "luksClose" name) t)))
(unless (zerop (car result))
(error "%s" (cdr result)))))