在 NixOS 上使用 sops-nix 进行密钥管理
密码和加密密钥文件之类的机密信息在计算领域无处不在。在配置 Linux 系统时,你迟早需要把某个密码放到某个地方——例如,当我把现有的 Linux 网络存储(NAS)环境迁移到 NixOS 时,我需要在 NixOS 配置中指定 Samba 密码(或者在 NixOS 之外手动管理它们)。对于个人电脑来说这没什么问题,但如果目标是共享系统配置(例如放在一个 Git 仓库里),我们就需要另一种解决方案:Secret Management(机密管理)。
什么是 Secret Management?
Secret Management 系统的基本思路是对静态存储的机密进行加密,也就是说,如果有人克隆了包含你的 NixOS 系统配置的 git 仓库,他们无法访问这些已加密的机密(因此也无法部署它们)。
从概念上讲,我们需要:
- 对机密进行加密,使目标系统能够解密。
- 对机密进行加密,使参与该配置工作的其他人也能解密。
- 让目标系统在运行时解密机密。
- 告诉我们的软件从哪里获取解密后的机密。
sops-nix 的设置
在本文中,我将展示如何使用 sops-nix 实现上述目标。以下是我们将用到的三个构建模块的简要概述:
- sops 是一个以加密形式在 git 中对机密进行版本控制的工具。
- sops 让你在添加/移除授权密钥时可以轻松地重新加密这些机密。
- sops 非常灵活,可以与大量其他工具/提供商配合使用。
- sops-nix 提供了将 sops 与 Nix/NixOS 集成的方式
- 将 sops 与
age(1)结合使用,让我们可以直接使用现有的 SSH 私钥(人类)或 SSH 主机私钥(机器),而无需另外管理一套密钥文件。
你可能想知道为什么我选择 sops-nix 而不是另一个竞争者 agenix?我第一次看 sops-nix 的设置说明时就觉得更容易理解,而且我希望能在其他场景下使用 sops,而不仅仅是配合 age。如果你对 agenix 感兴趣,可以看看 Andreas Gohr 关于 agenix 的博客文章。
第 1 步:准备工作
我在一台安装了 Nix 工具并启用了 Nix Flakes 的 Arch Linux 机器上执行了以下操作。点击链接还可以找到适用于 Debian 或 Fedora 等其他系统的说明。
第 2 步:从个人 SSH 密钥派生 age 身份
我不想额外管理一个密钥文件,所以我用 ssh-to-age 从我的 SSH 私钥文件派生出一个密钥——这个私钥文件我已经妥善备份了:
midna % mkdir -p $HOME/.config/sops/age/
midna % read -s SSH_TO_AGE_PASSPHRASE; export SSH_TO_AGE_PASSPHRASE
midna % nix run nixpkgs#ssh-to-age -- \
-private-key \
-i $HOME/.ssh/id_ed25519 \
-o $HOME/.config/sops/age/keys.txt(SSH_TO_AGE_PASSPHRASE 选项记录在 ssh-to-age README 中。)
为了显示这个 age 身份(私钥)对应的 age 接收方(公钥),我使用了:
midna % nix shell nixpkgs#age
midna 2 % age-keygen -y $HOME/.config/sops/age/keys.txt
age10e9tt2qwq90y5hvl35dau0sm5cm4qvegtw2a70v7sz5fy99de42s9d5nkf第 3 步:为远程机器获取 age 接收方
类似地,我从远程系统的 SSH 主机密钥派生出一个 age 接收方:
batchn % cat /etc/ssh/ssh_host_ed25519_key.pub | nix run nixpkgs#ssh-to-age
age1wnwfnrqhewjh39pmtyc8zhqw606znskt4h5p9s3pve4apd67gapqj6tr0k第 4 步:为 git 仓库配置 sops
在我的 git 仓库(nix-configs)中,每个 NixOS 系统对应一个子目录,即 tree(1) 显示如下:
├── batchn
│ ├── configuration.nix
│ ├── disk-config.nix
│ ├── flake.lock
│ ├── flake.nix
│ ├── hardware-configuration.nix
│ ├── Makefile
│ ├── secrets
│ │ └── example.yaml
├── wiki
│ ├── configuration.nix
│ ├── disk-config.nix
│ ├── flake.lock
│ ├── flake.nix
│ ├── hardware-configuration.nix
│ ├── Makefile
…在 git 仓库的根目录(与 batchn 目录同级)下,我创建了如下内容的 .sops.yaml:
keys:
- &admin_michael age10e9tt2qwq90y5hvl35dau0sm5cm4qvegtw2a70v7sz5fy99de42s9d5nkf
- &server_batchn age1wnwfnrqhewjh39pmtyc8zhqw606znskt4h5p9s3pve4apd67gapqj6tr0k
# …more server keys go here…
creation_rules:
- path_regex: batchn/secrets/[^/]+\.(yaml|json|env|ini)$
key_groups:
- age:
- *admin_michael
- *server_batchn管理的系统越多,需要配置的 keys 和 creation_rules 就越多。
创建规则告诉 sops 在加密文件时应使用哪些密钥。在我的环境中,每个系统通常只用一个文件,但可以想象,如果我想与他人只就系统的某一方面进行协作,可以把部分机密拆分到单独的文件中。
第 5 步:用 sops 管理一些机密
现在我们已经告诉 sops 要为哪些接收方加密,就可以通过运行以下命令,在配置好的编辑器中解密并编辑 secrets/example.yaml:
midna ~/nix-configs/batchn % nix run nixpkgs#sops secrets/example.yaml最简单的密钥文件只包含一个键,例如:
api-key: hello world :)保存并退出编辑器后,sops 会更新已加密的 secrets/example.yaml。
第 6 步:在 NixOS 中配置 sops
接下来,我们需要在 NixOS 中引用这个加密文件,并启用 sops-nix 集成,使解密后的机密在系统上可用。
在 flake.nix 中,我把 sops-nix 加入了 inputs 部分,并添加了 NixOS 模块。我展示完整的 diff,因为这些行所在的位置和内容同样重要:
--- c/batchn/flake.nix
+++ i/batchn/flake.nix
@@ -1,85 +1,93 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
disko.url = "github:nix-community/disko";
# Use the same version as nixpkgs
disko.inputs.nixpkgs.follows = "nixpkgs";
stapelbergnix.url = "github:stapelberg/nix";
zkjnastools.url = "github:stapelberg/zkj-nas-tools";
+ sops-nix = {
+ url = "github:Mic92/sops-nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
};
outputs =
{
nixpkgs,
disko,
stapelbergnix,
zkjnastools,
+ sops-nix,
...
}:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = false;
};
in
{
nixosConfigurations.batchn = nixpkgs.lib.nixosSystem {
inherit system;
inherit pkgs;
modules = [
disko.nixosModules.disko
./configuration.nix
stapelbergnix.lib.userSettings
# Use systemd for network configuration
stapelbergnix.lib.systemdNetwork
# Use systemd-boot as bootloader
stapelbergnix.lib.systemdBoot
# Run prometheus node exporter in tailnet
stapelbergnix.lib.prometheusNode
zkjnastools.nixosModules.zkjbackup
+ sops-nix.nixosModules.sops
];
};
formatter.${system} = pkgs.nixfmt-tree;
};
}然后,在 configuration.nix 中,我们告诉 sops-nix 使用 SSH 主机密钥作为身份、sops 可以在哪里找到我们的机密,以及 sops-nix 应该在远程系统上落实哪些机密:
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
sops.defaultSopsFile = ./secrets/example.yaml;
sops.secrets."api-key" = { };部署之后,我们就可以在运行中的系统上访问该机密:
batchn ~ % sudo cat /run/secrets/api-key
hello world :) %
batchn ~ %当然,即使重启机器,机密依然可用,无需重新部署:
batchn ~ % uptime
22:09:23 up 0:00, 1 user, load average: 0,32, 0,08, 0,03
batchn ~ % sudo cat /run/secrets/api-key
hello world :) %
batchn ~ %使用示例
现在机密已经存储在 /run/secrets 下的文件中了,我们该如何使用这些机密呢?
下面几节展示了几种常见方式。
使用示例:命令行参数(ExecStart 包装脚本)
假设你已经按如下方式在 NixOS 上把一个自定义 Go 服务器部署为 systemd 服务,并且想开始管理通过 -securecookie_hash_key 和 -securecookie_block_key 命令行参数传入的明文机密:
{
users.groups.fortuneserver = { };
users.users.fortuneserver = {
isSystemUser = true;
group = "fortuneserver";
};
systemd.services.fortuneserver = {
description = "fortuneserver";
documentation = [ "https://michael.stapelberg.ch" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "fortuneserver";
Group = "fortuneserver";
ExecStart = ''
"${pkgs.fortuneserver}/bin/fortuneserver" \
-securecookie_hash_key="some-secret-key" \
-securecookie_block_key="a-different-secret-key"
'';
};
};
}配合如下 sops 机密:
fortuneserver:
securecookie_hash_key: some-secret-key
securecookie_block_key: a-different-secret-key……我们需要调整 NixOS 配置,使其在运行时读取这些机密文件。由于 ExecStart 指令由 systemd 解释,不会经过 shell 处理,所以我们使用 writeShellScript 辅助函数,然后直接 cat 这些文件:
{
sops.secrets."fortuneserver/securecookie_hash_key" = {
owner = "fortuneserver";
restartUnits = [ "fortuneserver.service" ];
};
sops.secrets."fortuneserver/securecookie_block_key" = {
owner = "fortuneserver";
restartUnits = [ "fortuneserver.service" ];
};
users.groups.fortuneserver = { };
users.users.fortuneserver = {
isSystemUser = true;
group = "fortuneserver";
};
systemd.services.fortuneserver = {
description = "fortuneserver";
documentation = [ "https://michael.stapelberg.ch" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "fortuneserver";
Group = "fortuneserver";
ExecStart = pkgs.writeShellScript "fortuneserver-execstart" ''
"${pkgs.fortuneserver}/bin/fortuneserver" \
-securecookie_hash_key="$(cat /run/secrets/fortuneserver/securecookie_hash_key)" \
-securecookie_block_key="$(cat /run/secrets/fortuneserver/securecookie_block_key)"
'';
};
};
}使用示例:环境变量文件
如果相关服务不使用命令行参数,而是用环境变量来配置机密呢?我们可以把一个环境变量文件放入由 sops 管理的机密中:
translate-fe:
env: |
DEEPL_AUTH_KEY=my-deepl-key……然后让 systemd 从该机密文件应用这些环境变量:
{
sops.secrets."translate-fe/env" = {
owner = "translatefe";
restartUnits = [ "translate-fe.service" ];
};
systemd.services.translate-fe = {
documentation = [ "https://michael.stapelberg.ch" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "translatefe";
EnvironmentFile = [ config.sops.secrets."translate-fe/env".path ];
ExecStart = "${translatefeExecstart}/bin/translate-fe";
};
};
}如果你配置的是一个 NixOS 模块(而不是声明自定义服务),选项名称不一定总是 EnvironmentFile。例如,对于 oauth2-proxy 服务,你需要配置 services.oauth2-proxy.keyFile 选项:
services.oauth2-proxy = {
keyFile = config.sops.secrets."oauth2-proxy/env".path;
enable = true;
# …
};使用示例:systemd 凭据
在前面的例子中,我们把每个机密的 owner 配置为服务运行所用的用户账户。但如果不存在这样的用户账户呢——比如服务使用了 systemd 的 DynamicUser 特性?
我们可以使用 systemd 的 LoadCredential 特性!例如,我按如下方式向我的 Prometheus Alertmanager 提供 SMTP 密码:
{
sops.secrets."alertmanager/smtp_pw" = {
restartUnits = [ "alertmanager.service" ];
};
systemd.services.alertmanager.serviceConfig.LoadCredential = [
"smtp_pw:${config.sops.secrets."alertmanager/smtp_pw".path}"
];
services.prometheus.alertmanager = {
enable = true;
configuration = {
global = {
smtp_smarthost = "smtp.gmail.com:587";
smtp_from = "[email protected]";
smtp_auth_username = "[email protected]";
smtp_auth_password_file = "/run/credentials/alertmanager.service/smtp_pw";
};
# …remaining config goes here…
};
};
}使用示例:samba 用户/密码
在我的博客文章《把我的 NAS 从 CoreOS/Flatcar Linux 迁移到 NixOS》中,我描述了如何用一个 ExecStartPre shell 脚本(其技巧与上文介绍的非常相似)来配置 samba 用户和密码(来自 sops 管理的机密)。
结论
把机密作为单独加密的文件放在配置仓库中进行管理,在我看来是合理的!
age 能够直接使用 SSH 密钥,这让整个设置非常方便。为目标系统的 SSH 主机密钥加密机密,感觉非常优雅。
希望上面的例子足以让你高效地在 NixOS 中配置机密!
随机一篇博客