我有以下要求:
- 如果插入了特定的 USB 存储设备,系统会自动检测并安装它。安装位置信息
/etc/fstab
(当然)会显示在这里。 - 如果 USB 驱动器坏了,必须自动进行彻底清理;从而不会留下任何残留痕迹,例如它曾被安装(或仍然被安装)和/或任何未完成的 systemd 单元。
- 如果手动编辑了有问题的分区
umount
,那么所有内容也应该被清理,即使该棒仍然存在;将其从插头中拔出应该只会清理之前无法清理的内容(可能是单元.device
)。
为了以干净的方式实现上述操作,插入 USB 驱动器将导致自定义 systemd 单元的启动,然后我们将对其进行配置以完成其余操作。
写这个需要什么?
假设我们有一个有三个分区的 USB 驱动器:
/dev/sdc1
和/dev/sdc2
- 它们是 LUKS 加密的,以及/dev/sdc3
- 它们是未加密的。
例如,
>sudo blkid /dev/sdc{1,2,3}
/dev/sdc1: UUID="de9a000f-fa60-4050-8d11-864e97829b8a" TYPE="crypto_LUKS" PARTUUID="219578f5-5905-41cd-895d-7b937ac0756a"
/dev/sdc2: UUID="012059c1-6b88-4937-889a-a21448740492" TYPE="crypto_LUKS" PARTUUID="59c7f6f3-a5cb-41af-82fa-b515031d85d5"
/dev/sdc3: LABEL="README" UUID="9d262deb-2343-4096-bccf-bb26ed4415ad" BLOCK_SIZE="1024" TYPE="ext2" PARTUUID="165db3da-4ebb-4f59-ab09-ee5e5b26ed43"
以下UUID
是所使用的内容/dev/disk/by-uuid
:
>ls -l /dev/disk/by-uuid | grep sdc
lrwxrwxrwx 1 root root 10 Dec 22 18:27 012059c1-6b88-4937-889a-a21448740492 -> ../../sdc2
lrwxrwxrwx 1 root root 10 Dec 22 18:27 9d262deb-2343-4096-bccf-bb26ed4415ad -> ../../sdc3
lrwxrwxrwx 1 root root 10 Dec 22 18:27 de9a000f-fa60-4050-8d11-864e97829b8a -> ../../sdc1
由于设备名称/dev/sdc
相当随机,从现在开始我们应该只使用 UUID。
为了手动挂载未加密的分区,我们需要执行(确保 /mnt 和/或 /mnt/usb 上尚未挂载任何内容!):
UUID=9d262deb-2343-4096-bccf-bb26ed4415ad
SERIAL=408D5CBECAC3E7C0E9170AEC
sudo /bin/mkdir -p /mnt/usb/$SERIAL/part3
sudo mount -t ext2 /dev/disk/by-uuid/$UUID /mnt/usb/$SERIAL/part3
我确实选择使用 USB 设备的值ID_SERIAL_SHORT
,例如可以通过以下方式获取:
>udevadm info -n /dev/sdc3 | grep -E '(ID_SERIAL_SHORT|ID_FS_UUID)='
E: ID_SERIAL_SHORT=408D5CBECAC3E7C0E9170AEC
E: ID_FS_UUID=9d262deb-2343-4096-bccf-bb26ed4415ad
/etc/fstab
让我们通过向该文件添加以下内容来存储挂载点:
UUID=9d262deb-2343-4096-bccf-bb26ed4415ad /mnt/usb/408D5CBECAC3E7C0E9170AEC/part3 ext2 rw,noauto 0 0
请注意,此后我们可以执行以下操作:
sudo mount /dev/sdc3
只要该块设备具有预期的 UUID,它就能顺利完成挂载。
另外两个分区/dev/sdc{1,2}
已加密,首先需要
UUID1=573bfda0-69f5-4fb9-9d7e-333a70a51710
UUID2=6d7bdc1d-3eb0-4774-a9fb-3d1ac1027010
echo $LUKS_PASS | sudo cryptsetup -q luksOpen /dev/disk/by-uuid/$UUID1 usb-$UUID1
echo $LUKS_PASS | sudo cryptsetup -q luksOpen /dev/disk/by-uuid/$UUID2 usb-$UUID2
要执行的命令,我假设环境变量LUKS_PASS
包含解密 LUKS 分区所需的密码。这里的 UUID 是 blkid 返回的:
>sudo blkid /dev/sdc{1,2}
/dev/sdc1: UUID="573bfda0-69f5-4fb9-9d7e-333a70a51710" TYPE="crypto_LUKS" PARTUUID="caa57a9d-7389-4990-a243-34b19a179368"
/dev/sdc2: UUID="6d7bdc1d-3eb0-4774-a9fb-3d1ac1027010" TYPE="crypto_LUKS" PARTUUID="10813450-6dcc-4700-9e88-d0577c0c9aeb"
这将为/dev/mapper/usb-$UUID
每个分区创建一个设备,每个设备都有自己的 UUID:
>sudo blkid /dev/mapper/usb*
/dev/mapper/usb-573bfda0-69f5-4fb9-9d7e-333a70a51710: LABEL="gold1-2024-12-21" UUID="67f056be-dbbc-4f7a-979d-6ff077d16e93" BLOCK_SIZE="4096" TYPE="ext2"
/dev/mapper/usb-6d7bdc1d-3eb0-4774-a9fb-3d1ac1027010: LABEL="gold2-2024-12-21" UUID="94bfcc55-6b52-431f-bc76-3aa198c107c7" BLOCK_SIZE="4096" TYPE="ext2"
这些就是我们想要添加的 UUID /etc/fstab
。
为了让生活更轻松,我编写了以下脚本,该脚本/etc/fstab
在插入 USB 后运行时打印所需的配置(就我的特定情况而言):
# Get the device path.
DEVPATH=$(/bin/ls /dev/disk/by-id/usb-Kingston_DataTraveler* | grep -E -v -- '-part[0-9]+$')
# Get the serial of the USB stick.
ID_SERIAL_SHORT=$(udevadm info -n $DEVPATH | grep ID_SERIAL_SHORT | sed -e 's/.*ID_SERIAL_SHORT=//')
# Run over all existing partitions.
for p in $(/bin/ls $DEVPATH-part*); do
# Extract the "part?" string.
PART=$(echo $p | sed -r -e 's/.*-(part[0-9]+)$/\1/')
# Get the UUID of the block device of this partition iff it is a LUKS encrypted partition.
UUID=$(blkid --match-token TYPE=crypto_LUKS $p | sed -e 's/.* UUID="\([^ ]*\)".*/\1/' || true)
if [ -n "$UUID" ]; then
# Decrypt the partition.
echo $LUKS_PASS | sudo cryptsetup -q luksOpen /dev/disk/by-uuid/$UUID usb-$UUID
# Get the UUID of the encrypted partition.
UUID2=$(blkid /dev/mapper/usb-$UUID | sed -e 's/.* UUID="\([^ ]*\)".*/\1/')
echo -e "UUID=$UUID2\t/mnt/usb/$ID_SERIAL_SHORT/$PART\text2\trw,noauto\t0 0"
sudo cryptsetup luksClose usb-$UUID
else
# Get UUID of non-encrypted partition.
UUID=$(blkid $p | sed -e 's/.* UUID="\([^ ]*\)".*/\1/')
echo -e "UUID=$UUID\t/mnt/usb/$ID_SERIAL_SHORT/$PART\text2\trw,noauto\t0 0"
fi
done
导出后,LUKS_PASS
请确保运行上述脚本sudo -E
以保存环境。示例输出:
>sudo -E ./foo.sh
UUID=db97c4b0-8f92-4edc-bacb-dc90e62de2e2 /mnt/usb/408D5CBECAC3E7C0E9170AEC/part1 ext2 rw,noauto 0 0
UUID=c45c6a7a-1c0f-49c4-bcfb-15766114daa1 /mnt/usb/408D5CBECAC3E7C0E9170AEC/part2 ext2 rw,noauto 0 0
UUID=9d262deb-2343-4096-bccf-bb26ed4415ad /mnt/usb/408D5CBECAC3E7C0E9170AEC/part3 ext2 rw,noauto 0 0
设置完毕/etc/fstab
并确切知道需要什么命令后,剩下的就是使用udev
和来自动执行此操作systemd units
。
问题是:如何做?
编辑:TLDR;您想先阅读我的其他答案。
这是我研究的逐步报告。
调试日志记录
可能有更好的方法,但以下方法有效:我创建了一个可执行脚本,
/usr/local/sbin/log_service
内容如下:当使用它运行时,
/bin/systemd-cat -t usbtest
它会将脚本写入标准输出的输出写入日志。为了监控输出,我们在单独的终端窗口中运行:
实时监控输出。
假冒 .device
首先,我创建了一个虚假的服务,将其用作虚假的“.device”(尽管这是一个.service)。
创建以下文件
/etc/systemd/system/A.service
:然后运行
sudo systemctl daemon-reload
以使正在运行的 systemd 知道它。尝试启动新服务:
这会导致立即的日志输出:
原因是正在运行的脚本 (
/usr/local/sbin/log_service
) 立即返回。由于我们想要模拟可以通过 systemctl 启动和停止的服务,因此我们添加了以下行RemainAfterExit=yes
:现在我们可以
sudo systemctl start A
分别了sudo systemctl stop A
。假挂载 .service
让我们对新服务再次执行此操作
B
。我们的想法是,我们希望在启动B
时自动启动,并且停止时必须导致停止。但是,手动停止不应停止。A
A
B
B
A
因为 B 将成为 A 的依赖项,并且两者都进行日志记录,所以我们希望 B在A 完成日志记录(A 的 ExecStart 完成)后启动,因此
A.service
再次更改并添加Type=oneshot
;我们希望确保所有 ExecStart 在任何依赖服务启动之前完成。请注意,另一个区别是,对于 类型oneshot
,如果有多ExecStart
行,则它们将按顺序完成,并且下一行仅在前一行完成后启动Type=simple
,而 (默认值)则ExecStart
并行启动所有行(因为它们预计不会快速完成,或者根本不会完成)。但是,这与我们无关:我们只有一行ExecStart
(一个确实“立即”返回的行,否则您无法使用oneshot
)。我不会重复我在这里尝试过的所有事情;只重复最终有效的结果:
(不要忘记重新运行
sudo systemctl daemon-reload
)。我们的需要
After=A.service
不言而喻:如果设备不存在(即 .device 单元处于活动状态),则无法安装该设备;因此,我们不想在 A 启动之后才启动 B。为了使每次启动 A 时都启动 B,仅有 是不够的
WantedBy=A.service
,我们需要添加Wants=B.service
到[Unit]
部分A.service
!这样,如果我们启动 A,B 也会启动。如果我们尝试启动 B,我们不希望 A 启动:这没有意义。因此我们使用
Requisite=A.service
而不是Requires=A.service
(或BindsTo=A.service
)。因此,如果 A 未处于活动状态,这将巧妙地拒绝启动 B(根据文档):而不是启动 A。
一旦两者都处于活动状态,我们也可以停止 B;这只会停止 B(我们甚至无法停止 .device,因为那没有意义:它仍然插入)。
systemctl start A
此后运行(即使 A已经处于活动状态)会再次启动 B,这可能正是我们想要的。最后,在两个活动状态都处于活动状态时停止 A,会先停止 B,然后停止 A - 这正是我们想要的。然而,这很奇怪,因为从文档中我了解到这是一个
BindsTo=
具有以下描述的功能:它准确地描述了我们需要什么(“设备单元可能被拔掉”)。虽然的文档
Requisite=
是所以,我们想要这个(如果 A 尚未启动,则启动 B 将失败,而不是尝试启动 B),但它没有提到
BindsTo
我们还需要的部分(停止 A 将停止 B)。但是,如果我添加
BindsTo=A.service
的功能Requisite=
无效,启动 B 只会启动 A(首先)。systemd 中存在错误(在 Arch 上使用版本 257-1 进行测试)?使用真实设备(udev)
我们可以添加一条
udev
规则来检测我想要检测的 USB 存储棒的插入情况。请注意,即使没有这样的规则,插入 USB 存储棒也会导致.device
创建单元:但是因为这些设备的名称
.device
可能会改变(如果仅基于我插入它们的位置),我们不能像以前那样使用它们的名称A.service
。此外,我们需要Wants=B.service
为这些设备添加 - 这些设备没有。最后,我们不想B.service
为任何USB 设备及其分区和母设备触发,而只想为我上面列出的分区触发。所有这些都可以通过创建新的 udev 规则来实现。
当 USB 驱动器插入时(它有第三个分区 /dev/sdc3),我们可以运行例如:
请注意,这告诉我们 udev 知道一切(特别是 UUID 和 ID_SERIAL_SHORT),但 SYSTEMD_WANTS 尚未设置。我们需要将其设置为
B.service
。接下来我们通过创建内容如下的文件来创建一个新的 udev 规则
/etc/udev/rules.d/69-gold-usb.rules
:并使用 重新加载
sudo udevadm control --reload-rules
。sudo udevadm test /sys/class/block/sdc3
再次运行时(如上所示),我们得到相同的结果,但多出一行:表明该规则有效。
我们可以在运行时插入和拔出 USB 来测试同样的事情
添加 USB 记忆棒后,会出现以下结果:
拔掉电源插头时也一样
remove
。请注意规则是如何针对这两个分区
/dev/sdc
以及三个分区中的每一个分区触发的。因为我们只对分区感兴趣,所以让我们添加ENV{DEVTYPE}=="partition"
到 udev 规则中:我们现在有一条
udev
规则,可以检测三个 USB 棒中的一个的插入,然后启动B.service
。但是B.service
它本身缺少正确的和After=
,因此拔下 USB 棒还不能达到预期的效果。Requisite=
WantedBy=
将 .device 的依赖添加到 B.service
如果我们只有一个分区需要处理(比如,这个特定 USB 驱动器的 /dev/sdc3),那么添加依赖项的一个直接方法似乎是使用以下方法之一
DEVLINKS
- 最值得注意的是,当然,对于给定的分区,这是完全固定的。但是,这些路径名必须转义:所有斜杠都必须替换为破折号 (
-
),如果我们尝试:B.service
然后我们在启动时得到以下有趣的错误:-
请注意,UUID 中的也被转换为/
。生成此字符串的正确方法是使用但是,使用这个字符串
B.service
代替字符串A.service
表明,即使我们拔掉 USB 棒,也不会B
停止(正如查看文档所预期的那样)。因此,我们确实需要添加一行。BindsTo=
B.service
然后变成:有趣的是,现在尝试启动
sudo systemctl start B
不会失败,但如果没有插入 USB,它就会挂起,直到您将其插入。我想这还挺巧妙的。无论如何,插入 USB 都会自动启动
B
。此时我们可以B
像以前一样手动停止,此时B
插入 USB 后系统会停止,然后我们可以手动(重新)启动,B
而不会挂起。我们可以将(动态)UUID 传递给服务,稍微像 hack 一样,操作如下:
and then use
%i
in the .service to access the UUID. I call this a "hack" because it only works because an UUID doesn't contain illegal character for an escaped string: you SHOULD pass a systemd escaped string here; in that case %i would be that escaped string and %I would be the original, unescaped string.However, this is not usable: we NEED the escaped string for
After=
,Requisite=
,BindsTo=
andWantedBy=
because there is no way to manipulate %i: it must be used as-is.There is a way to create the escaped string during the udev rule, but due to a bug in udev it is impossible to actually use that if it contains any backslashes (it is beyond me how such a horrible bug can exist for years without anyone fixing it).
The only remaining option therefore is to use
SYMLINK
with a path that doesn't contain any characters that need to be escaped with a backslash (aka, no '-' characters). In fact, we can pass any information using this trick!This way we can pass (any) information (in this case only ID_FS_UUID) while still using %i for the dependency stuff in the
@.service
template! We only have to make sure that the%c
, the output ofPROGRAM
(and thus the SYMLINK) does not contain any characters that need to be escaped or are escaped with a backslash.The
/usr/local/sbin/udev-escape
that I use contains:Simple, but you have to admit, brilliant.
The counter part is
/usr/local/sbin/udev-unescape
:Warning: the output of this program can contain ANYTHING, including newline characters. So you should be very careful how to use it in a service, that is being run by root and stuff. Probably best to apply some filtering.
Using the above,
%i
is going to have a value likeMDQ2YmE4MWItN2Q5Yy00YTU0LWE2OWYtNmZiYTA2YWM3NzU0Cg__
. The device path then is/dev/%I
, or/dev/usb/MDQ2YmE4MWItN2Q5Yy00YTU0LWE2OWYtNmZiYTA2YWM3NzU0Cg__
, which will be a symlink to for example/dev/sdc3
. And inside the service scripts we can retrieve our information by passing%i
toudev-unescape
:Hence,
[email protected]
now becomes:Passing arbitrary information from udev to a service.
Udev has a lot of information available, it would be a waste not to pass that. With the USB stick inserted, you can now easily see which partitions are available:
Lets say we want to investigate what information is available for sdc1. We can list the
ATTR
values with:And we can inspect the udev environment variables using - say:
Note that
SYSTEMD_WANTS
has an empty%i
because we never really did runudev-escape
in this case.We're going to need the value of
ID_FS_TYPE
too, so lets change the udev rule to its FINAL VERSION:Here I added
$env{ID_FS_TYPE}
to the encoding of the symlink.Adding auto-mounting
We're now done with udev.
[email protected]
is started properly and we can pass any information (from udev) to it that we need.To finish this project, lets rename
[email protected]
to something normal and change it to call some real scripts.What I did was create the file
/etc/systemd/system/[email protected]
with the following content:This incorporates everything we learned about the unit file leaving us only with the task of writing the scripts.
TODO
This answer is getting so long that I decided to post it already. I'll make updates to the remaining parts once I got it fully working.
My current files are:
/usr/local/sbin/usb-gold-start
:/usr/local/sbin/usb-gold-stop
:EDIT (28 December 2024):
It turned out that also the
SYMLINK
trick can't be used, because that ".device" is killed during thecryptsetup
, causing the .service to be terminated beforecryptsetup
returns. The latter still gets the chance to finish what it is doing, but mounting afterwards is a no-go.Therefore, we need(ed) another method: One can use
IMPORT{program}
which preserves backslashes. For example:IMPORT{program}="/usr/local/sbin/udev-gold-usb-systemd_wants.sh $env{ID_SERIAL_SHORT}-part$env{PARTN}"
, in which case it is theoretically possible to write double backslashes to the output ofudev-gold-usb-systemd_wants.sh
which then become single backslashes. However, this is deprecated because it has been decided (I think) that this 8-year-old bug just has to be fixed (see the previous link to the systemd issue; which has considerably grown since I added it). For the time being, it is best to stay away from trying to pass backslashes completely.This answer is written after I solved the problem (as opposed to writing it while I was doing all the research).
systemd already supports mounting encrypted filesystems out of the box. The following points are required:
/etc/fstab
for each partition, from which systemd-fstab-generator will generate the required mount unit./etc/crypttab
for each partition that is encrypted, from which systemd-cryptsetup-generator will generate the required[email protected]
template unit.While implementing this we have to take into account that udev rules and systemd escape functionality do not play well together. We need to avoid the use of backslashes or it either won't work or will break in the near future when these bugs are fixed.
The custom service is only required because a mount unit can not be a template.
The udev rule
The udev rule can contain tests like
ATTR{removable}=="1"
. To get a full list of possibleATTR
tests, run:where
/dev/sdc1
is the device path of a currently inserted USB stick partition.Likewise, one can get a list of possible
ENV{<property name>}=="<regular expression>"
values with:which will list lines as
ID_SERIAL_SHORT='408D5CBF5F0AE7C0E9150B70'
. For example, my/etc/udev/rules.d/69-gold-usb.rules
contains:And yes, you can use backslashes to split up long lines. Just don't forget to use
==
(not=
) and don't forget the comma's.Note that this rule uses
IMPORT{program}
which is currently (December 2024) the only way to pass double backslashes to aSYSTEMD_WANTS
property, but as the behavior of systemd is going to change in that regard (no longer requiring double backslashes), we shouldn't be using backslashes. This means that we can use slashes ('/' for paths) which are converted to a hyphen ('-'), but we can't use a hypen ('-' as appear in UUIDs) which would be escaped as '\x2d'.The script
/usr/local/sbin/udev-gold-usb-systemd_wants.sh
contains:and we are passing to it something like
408D5CBF5F0AE7C0E9150B70-part1
where the hypen is an escaped '/'! This string therefore must be used as, and thus exist, as part of a path that contains"408D5CBF5F0AE7C0E9150B70/part1"
or it can't work.Don't forget to run
to re-read all udev rules after you made changes.
The template service
[email protected]
As said before, this custom service is only required to allow the udev rule to have a variable path (although, I copied the idea from here without really testing if we can't specify the .mount directly).
For the rest is does nothing, but a
StartExec
is required. Therefore this unit file contains:And as
.mount
unit files use their mount point as name, this means that this specifies the mount point as/mnt/usb/408D5CBF5F0AE7C0E9150B70/part1
! Fortunately we don't have to create that directory ourselves, see below.The
/etc/fstab
entry./etc/fstab
can contain entries that will causesystemd
to generate.mount
unit files, normally at boot time, but you can re-trigger this by running:Here is an example of what is in my
/etc/fstab
:Note how this contains the same mount point path
/mnt/usb/408D5CBF5F0AE7C0E9150B70/part1
, otherwise it wouldn't cause the generation of themnt-usb-408D5CBF5F0AE7C0E9150B70-part1.mount
unit.The first column,
UUID=5b214986-7db3-439c-99ba-f4da0cebdf34
, contains the UUID of the partition that must be mounted: the decrypted/dev/mapper/usba52ee8f0a8fa42008c19d798303ac118
, not the encrypted/dev/sdc1
partition.x-mount.mkdir
causes the mount point path to be automatically created if it doesn't exist. Andx-systemd.requires=systemd-cryptsetup@usba52ee8f0a8fa42008c19d798303ac118.service
makes sure thatcryptsetup
is being run first in order to create that decrypted/dev/mapper/usba52ee8f0a8fa42008c19d798303ac118
that we want to mount.Also note that partition 3 is not encrypted, so
046ba81b-7d9c-4a54-a69f-6fba06ac7754
is the UUID of/dev/sdc3
and that line doesn't have ax-systemd.requires=
.The
/etc/crypttab
entry.For the encrypted partitions to work, we also need an entry in
/etc/crypttab
:Here
/dev/disk/by-uuid/a52ee8f0-a8fa-4200-8c19-d798303ac118
is the encrypted partition (it points to/dev/sdc1
) andusba52ee8f0a8fa42008c19d798303ac118
is a random id, which as it turns out, can not contain backslashes [...]. In order to avoid collisions I used the UUID, but had to remove the hypens from it.Entering the pass phrase to unlock the LUKS partitions.
With the above in place and having reloaded the configs (see above), inserting the USB stick will send a broadcast message to all tty's with something like:
Just hit Enter to see your prompt again (assuming it was still empty and your weren't in the middle of typing), and run:
This will then allow you to enter whatever secret is required. If the USB contains more than one partition, but have the same pass phrase, then you only have to enter it once (the second one will try the last entered pass phrase first and since that works won't require you to enter it again). I didn't test what happens if they have a different pass phrase, I can imagine that in that case you have a problem; maybe need to run
sudo systemd-tty-ask-password-agent
again after a failure or something like that.Using a YubiKey
Install the package
libfido2
. Then, with your YubiKey inserted, run:(and afterwards for all other encrypted partitions, of course). This will ask you to enter the passphrase of
/dev/sdc1
, then it will ask for the FIDO2 PIN. If you didn't change that yet, it will be the default123456
.You can change this PIN with the following command:
Doesn't matter if you do this before or after. It has no effect on the FIDO credentials.
You can inspect the result with
This now should show one more key under
Keyslots:
, at the very least the one containing your pass phrase, but also a new one. UnderTokens:
you will see an entry withsystemd-fido2
, and a reference to the Keyslot that it uses.To make the system use the YubiKey, change the entries in your
/etc/crypttab
to containfido2-device=auto
. For example,and rerun
sudo systemctl daemon-reload
.If now you insert the USB key it should do a broadcast message as before, but if then you run
sudo systemd-tty-ask-password-agent
it will ask forPlease enter LUKS2 token PIN:
. Enter the PIN that you set (or still 123456) and then tap your YubiKey once for every partition that needs to be decrypted.Now your partitions should be mounted. If they are not mounted then you are in deep shit because I didn't explain anything, I just hand-held you with the final answer :(. Expect many days of reading documents and learning all kinds of commands to find out intermediate results and debug output (e.g.
journalctl -b -t systemd
). Asking an A.I. for help with learning these commands actually helps here.Finally, here is a little script that I used to print the required entries for
/etc/fstab
and/etc/crypttab
, because that is a lot of work and I had three of these USB sticks (in case of failure of one in the future):