Merge branch 'master' of github.com:Mic92/sops-nix into nested-secrets

Signed-off-by: iosmanthus <myosmanthustree@gmail.com>
This commit is contained in:
iosmanthus 2024-11-27 15:28:22 +08:00
commit d953133e37
No known key found for this signature in database
GPG key ID: DEE5BAABFE092169
43 changed files with 2962 additions and 1188 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@V27
- uses: cachix/install-nix-action@v30
- name: Add keys group (needed for go tests)
run: sudo groupadd keys
- name: Run unit tests

View file

@ -14,7 +14,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Install Nix
uses: cachix/install-nix-action@V27
uses: cachix/install-nix-action@v30
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
nix_path: nixpkgs=channel:nixos-unstable

View file

@ -4,19 +4,26 @@ on:
workflow_dispatch:
schedule:
- cron: '51 2 * * 0'
permissions:
pull-requests: write
jobs:
createPullRequest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@V27
uses: cachix/install-nix-action@v30
with:
extra_nix_config: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@v24
with:
token: ${{ secrets.GH_TOKEN_FOR_UPDATES }}
pr-labels: |
merge-queue
- name: Update flakes
run: |
nix flake update
pushd dev/private
nix flake update
popd
nix run .#update-dev-private-narHash
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7

View file

@ -1,16 +1,14 @@
queue_rules:
- name: default
merge_conditions:
- check-success=buildbot/nix-eval
defaults:
actions:
queue:
merge_method: rebase
pull_request_rules:
- name: merge using the merge queue
conditions:
queue_conditions:
- base=master
- label~=merge-queue|dependencies
merge_conditions:
- check-success=buildbot/nix-build
merge_method: rebase
pull_request_rules:
- name: refactored queue action rule
conditions: []
actions:
queue:

View file

@ -674,8 +674,7 @@ JSON/YAML files. Unlike the other two formats, for binary files, one file corres
To encrypt an binary file use the following command:
``` console
$ cp /etc/krb5/krb5.keytab krb5.keytab
$ sops -e krb5.keytab
$ sops -e /etc/krb5/krb5.keytab > krb5.keytab
# an example of what this might result in:
$ head krb5.keytab
{
@ -708,6 +707,44 @@ This is how it can be included in your `configuration.nix`:
}
```
## Emit plain file for yaml and json formats
By default, sops-nix extracts a single key from yaml and json files. If you
need the plain file instead of extracting a specific key from the input document,
you can set `key` to an empty string.
For example, the input document `my-config.yaml` likes this:
```yaml
my-secret1: ENC[AES256_GCM,data:tkyQPQODC3g=,iv:yHliT2FJ74EtnLIeeQtGbOoqVZnF0q5HiXYMJxYx6HE=,tag:EW5LV4kG4lcENaN2HIFiow==,type:str]
my-secret2: ENC[AES256_GCM,data:tkyQPQODC3g=,iv:yHliT2FJ74EtnLIeeQtGbOoqVZnF0q5HiXYMJxYx6HE=,tag:EW5LV4kG4lcENaN2HIFiow==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
...
```
This is how it can be included in your NixOS module:
```nix
{
sops.secrets.my-config = {
format = "yaml";
sopsFile = ./my-config.yaml;
key = "";
};
}
```
Then, it will be mounted as `/run/secrets/my-config`:
```yaml
my-secret1: hello
my-secret2: hello
```
## Use with home manager
sops-nix also provides a home-manager module.
@ -786,6 +823,31 @@ The secrets are decrypted in a systemd user service called `sops-nix`, so other
}
```
### Qubes Split GPG support
If you are using Qubes with the [Split GPG](https://www.qubes-os.org/doc/split-gpg),
then you can configure sops to utilize the `qubes-gpg-client-wrapper` with the `sops.gnupg.qubes-split-gpg` options.
The example above updated looks like this:
```nix
{
sops = {
gnupg.qubes-split-gpg = {
enable = true;
domain = "vault-gpg";
};
defaultSopsFile = ./secrets.yaml;
secrets.test = {
# sopsFile = ./secrets.yml.enc; # optionally define per-secret files
# %r gets replaced with a runtime directory, use %% to specify a '%'
# sign. Runtime dir is $XDG_RUNTIME_DIR on linux and $(getconf
# DARWIN_USER_TEMP_DIR) on darwin.
path = "%r/test.txt";
};
};
}
```
## Use with GPG instead of SSH keys
If you prefer having a separate GPG key, sops-nix also comes with a helper tool, `sops-init-gpg-key`:

11
checks/darwin.nix Normal file
View file

@ -0,0 +1,11 @@
{
imports = [
../modules/nix-darwin/default.nix
];
documentation.enable = false;
sops.secrets.test_key = { };
sops.defaultSopsFile = ../pkgs/sops-install-secrets/test-assets/secrets.yaml;
sops.age.generateKey = true;
system.stateVersion = 5;
}

15
checks/home-manager.nix Normal file
View file

@ -0,0 +1,15 @@
{ config, ... }: {
imports = [
../modules/home-manager/sops.nix
];
home.stateVersion = "25.05";
home.username = "sops-user";
home.homeDirectory = "/home/sops-user";
home.enableNixpkgsReleaseCheck = false;
sops.age.generateKey = true;
sops.age.keyFile = "${config.home.homeDirectory}/.age-key.txt";
sops.secrets.test_key = { };
sops.defaultSopsFile = ../pkgs/sops-install-secrets/test-assets/secrets.yaml;
}

596
checks/nixos-test.nix Normal file
View file

@ -0,0 +1,596 @@
{ lib, testers }:
let
testAssets = ../pkgs/sops-install-secrets/test-assets;
userPasswordTest =
name: extraConfig:
testers.runNixOSTest {
inherit name;
nodes.machine =
{ config, lib, ... }:
{
imports = [
../modules/sops
extraConfig
];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = testAssets + "/secrets.yaml";
secrets.test_key.neededForUsers = true;
secrets."nested/test/file".owner = "example-user";
};
system.switch.enable = true;
users.users.example-user = lib.mkMerge [
(lib.mkIf (!config.systemd.sysusers.enable) {
isNormalUser = true;
hashedPasswordFile = config.sops.secrets.test_key.path;
})
(lib.mkIf config.systemd.sysusers.enable {
isSystemUser = true;
group = "users";
hashedPasswordFile = config.sops.secrets.test_key.path;
})
];
};
testScript =
''
start_all()
machine.wait_for_unit("multi-user.target")
machine.succeed("getent shadow example-user | grep -q :test_value:") # password was set
machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # regular secrets work...
user = machine.succeed("stat -c%U /run/secrets/nested/test/file").strip() # ...and are owned...
assert user == "example-user", f"Expected 'example-user', got '{user}'"
machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password still exists
# BUG in nixos's overlayfs... systemd crashes on switch-to-configuration test
''
+ lib.optionalString (!(extraConfig ? system.etc.overlay.enable)) ''
machine.succeed("/run/current-system/bin/switch-to-configuration test")
machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # the regular secrets still work after a switch
machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password is still present after a switch
'';
};
in
{
ssh-keys = testers.runNixOSTest {
name = "sops-ssh-keys";
nodes.server =
{ ... }:
{
imports = [ ../modules/sops ];
services.openssh.enable = true;
services.openssh.hostKeys = [
{
type = "rsa";
bits = 4096;
path = testAssets + "/ssh-key";
}
];
sops.defaultSopsFile = testAssets + "/secrets.yaml";
sops.secrets.test_key = { };
};
testScript = ''
start_all()
server.succeed("cat /run/secrets/test_key | grep -q test_value")
'';
};
pruning = testers.runNixOSTest {
name = "sops-pruning";
nodes.machine =
{ lib, ... }:
{
imports = [ ../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = testAssets + "/secrets.yaml";
secrets.test_key = { };
keepGenerations = lib.mkDefault 0;
};
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
specialisation.pruning.configuration.sops.keepGenerations = 10;
};
testScript = ''
# Force us to generation 100
machine.succeed("mkdir /run/secrets.d/{2..99} /run/secrets.d/non-numeric")
machine.succeed("ln -fsn /run/secrets.d/99 /run/secrets")
machine.succeed("/run/current-system/activate")
machine.succeed("test -d /run/secrets.d/100")
# Ensure nothing is pruned, these are just random numbers
machine.succeed("test -d /run/secrets.d/1")
machine.succeed("test -d /run/secrets.d/90")
machine.succeed("test -d /run/secrets.d/non-numeric")
machine.succeed("/run/current-system/specialisation/pruning/bin/switch-to-configuration test")
print(machine.succeed("ls -la /run/secrets.d/"))
# Ensure stuff was properly pruned.
# We are now at generation 101 so 92 must exist when we keep 10 generations
# and 91 must not.
machine.fail("test -d /run/secrets.d/91")
machine.succeed("test -d /run/secrets.d/92")
machine.succeed("test -d /run/secrets.d/non-numeric")
'';
};
age-keys = testers.runNixOSTest {
name = "sops-age-keys";
nodes.machine =
{ config, ... }:
{
imports = [ ../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = testAssets + "/secrets.yaml";
secrets = {
test_key = { };
test_key_someuser_somegroup = {
uid = config.users.users."someuser".uid;
gid = config.users.groups."somegroup".gid;
key = "test_key";
};
test_key_someuser_root = {
uid = config.users.users."someuser".uid;
key = "test_key";
};
test_key_root_root = {
key = "test_key";
};
test_key_1001_1001 = {
uid = 1001;
gid = 1001;
key = "test_key";
};
};
};
users.users."someuser" = {
uid = 1000;
group = "somegroup";
isNormalUser = true;
};
users.groups."somegroup" = {
gid = 1000;
};
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
};
testScript = ''
start_all()
machine.succeed("cat /run/secrets/test_key | grep -q test_value")
with subtest("test ownership"):
machine.succeed("[ $(stat -c%u /run/secrets/test_key_someuser_somegroup) = '1000' ]")
machine.succeed("[ $(stat -c%g /run/secrets/test_key_someuser_somegroup) = '1000' ]")
machine.succeed("[ $(stat -c%U /run/secrets/test_key_someuser_somegroup) = 'someuser' ]")
machine.succeed("[ $(stat -c%G /run/secrets/test_key_someuser_somegroup) = 'somegroup' ]")
machine.succeed("[ $(stat -c%u /run/secrets/test_key_someuser_root) = '1000' ]")
machine.succeed("[ $(stat -c%g /run/secrets/test_key_someuser_root) = '0' ]")
machine.succeed("[ $(stat -c%U /run/secrets/test_key_someuser_root) = 'someuser' ]")
machine.succeed("[ $(stat -c%G /run/secrets/test_key_someuser_root) = 'root' ]")
machine.succeed("[ $(stat -c%u /run/secrets/test_key_1001_1001) = '1001' ]")
machine.succeed("[ $(stat -c%g /run/secrets/test_key_1001_1001) = '1001' ]")
machine.succeed("[ $(stat -c%U /run/secrets/test_key_1001_1001) = 'UNKNOWN' ]")
machine.succeed("[ $(stat -c%G /run/secrets/test_key_1001_1001) = 'UNKNOWN' ]")
'';
};
age-ssh-keys = testers.runNixOSTest {
name = "sops-age-ssh-keys";
nodes.machine = {
imports = [ ../modules/sops ];
services.openssh.enable = true;
services.openssh.hostKeys = [
{
type = "ed25519";
path = testAssets + "/ssh-ed25519-key";
}
];
sops = {
defaultSopsFile = testAssets + "/secrets.yaml";
secrets.test_key = { };
# Generate a key and append it to make sure it appending doesn't break anything
age = {
keyFile = "/tmp/testkey";
generateKey = true;
};
};
};
testScript = ''
start_all()
machine.succeed("cat /run/secrets/test_key | grep -q test_value")
'';
};
pgp-keys = testers.runNixOSTest {
name = "sops-pgp-keys";
nodes.server =
{ lib, config, ... }:
{
imports = [ ../modules/sops ];
users.users.someuser = {
isSystemUser = true;
group = "nogroup";
};
sops.gnupg.home = "/run/gpghome";
sops.defaultSopsFile = testAssets + "/secrets.yaml";
sops.secrets.test_key.owner = config.users.users.someuser.name;
sops.secrets."nested/test/file".owner = config.users.users.someuser.name;
sops.secrets.existing-file = {
key = "test_key";
path = "/run/existing-file";
};
# must run before sops
system.activationScripts.gnupghome = lib.stringAfter [ "etc" ] ''
cp -r ${testAssets + "/gnupghome"} /run/gpghome
chmod -R 700 /run/gpghome
touch /run/existing-file
'';
# Useful for debugging
#environment.systemPackages = [ pkgs.gnupg pkgs.sops ];
#environment.variables = {
# GNUPGHOME = "/run/gpghome";
# SOPS_GPG_EXEC="${pkgs.gnupg}/bin/gpg";
# SOPSFILE = "${testAssets + "/secrets.yaml"}";
#};
};
testScript = ''
def assertEqual(exp: str, act: str) -> None:
if exp != act:
raise Exception(f"{exp!r} != {act!r}")
start_all()
value = server.succeed("cat /run/secrets/test_key")
assertEqual("test_value", value)
server.succeed("runuser -u someuser -- cat /run/secrets/test_key >&2")
value = server.succeed("cat /run/secrets/nested/test/file")
assertEqual(value, "another value")
target = server.succeed("readlink -f /run/existing-file")
assertEqual("/run/secrets.d/1/existing-file", target.strip())
'';
};
templates = testers.runNixOSTest {
name = "sops-templates";
nodes.machine =
{ config, ... }:
{
imports = [ ../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = testAssets + "/secrets.yaml";
secrets.test_key = { };
# Verify that things work even with `neededForUsers` secrets. See
# <https://github.com/Mic92/sops-nix/issues/659>.
secrets."nested/test/file".neededForUsers = true;
};
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
sops.templates.test_template = {
content = ''
This line is not modified.
The next value will be replaced by ${config.sops.placeholder.test_key}
This line is also not modified.
'';
mode = "0400";
owner = "someuser";
group = "somegroup";
};
sops.templates.test_default = {
content = ''
Test value: ${config.sops.placeholder.test_key}
'';
path = "/etc/externally/linked";
};
users.groups.somegroup = { };
users.users.someuser = {
isSystemUser = true;
group = "somegroup";
};
};
testScript = ''
def assertEqual(exp: str, act: str) -> None:
if exp != act:
raise Exception(f"{exp!r} != {act!r}")
start_all()
machine.succeed("[ $(stat -c%U /run/secrets/rendered/test_template) = 'someuser' ]")
machine.succeed("[ $(stat -c%G /run/secrets/rendered/test_template) = 'somegroup' ]")
machine.succeed("[ $(stat -c%U /run/secrets/rendered/test_default) = 'root' ]")
machine.succeed("[ $(stat -c%G /run/secrets/rendered/test_default) = 'root' ]")
expected = """\
This line is not modified.
The next value will be replaced by test_value
This line is also not modified.
"""
rendered = machine.succeed("cat /run/secrets/rendered/test_template")
expected_default = """\
Test value: test_value
"""
rendered_default = machine.succeed("cat /run/secrets/rendered/test_default")
assertEqual(expected, rendered)
assertEqual(expected_default, rendered_default)
# Confirm that `test_default` was symlinked to the appropriate place.
realpath = machine.succeed("realpath /etc/externally/linked").strip()
assertEqual(realpath, "/run/secrets.d/1/rendered/test_default")
'';
};
restart-and-reload = testers.runNixOSTest {
name = "sops-restart-and-reload";
nodes.machine =
{ config, ... }:
{
imports = [ ../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = testAssets + "/secrets.yaml";
secrets.test_key = {
restartUnits = [
"restart-unit.service"
"reload-unit.service"
];
reloadUnits = [ "reload-trigger.service" ];
};
templates.test_template = {
content = ''
this is a template with
a secret: ${config.sops.placeholder.test_key}
'';
restartUnits = [
"restart-unit.service"
"reload-unit.service"
];
reloadUnits = [ "reload-trigger.service" ];
};
};
system.switch.enable = true;
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
systemd.services."restart-unit" = {
description = "Restart unit";
# not started on boot
serviceConfig = {
ExecStart = "/bin/sh -c 'echo ok > /restarted'";
};
};
systemd.services."reload-unit" = {
description = "Reload unit";
wantedBy = [ "multi-user.target" ];
reloadIfChanged = true;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "/bin/sh -c true";
ExecReload = "/bin/sh -c 'echo ok > /reloaded'";
};
};
systemd.services."reload-trigger" = {
description = "Reload trigger unit";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "/bin/sh -c true";
ExecReload = "/bin/sh -c 'echo ok > /reloaded'";
};
};
};
testScript = ''
def assertOutput(output, *expected_lines):
expected_lines = list(expected_lines)
# Remove unrelated fluff that shows up in the output of `switch-to-configuration`.
prefix = "setting up /etc...\n"
if output.startswith(prefix):
output = output.removeprefix(prefix)
actual_lines = output.splitlines(keepends=False)
if actual_lines != expected_lines:
raise Exception(f"{actual_lines} != {expected_lines}")
machine.wait_for_unit("multi-user.target")
machine.fail("test -f /restarted")
machine.fail("test -f /reloaded")
# Nothing is to be restarted after boot
machine.fail("ls /run/nixos/*-list")
# Nothing happens when the secret is not changed
machine.succeed("/run/current-system/bin/switch-to-configuration test")
machine.fail("test -f /restarted")
machine.fail("test -f /reloaded")
# Ensure the secret is changed
machine.succeed(": > /run/secrets/test_key")
# The secret is changed, now something should happen
machine.succeed("/run/current-system/bin/switch-to-configuration test")
# Ensure something happened
machine.succeed("test -f /restarted")
machine.succeed("test -f /reloaded")
# Cleanup the marker files.
machine.succeed("rm /restarted /reloaded")
# Ensure the template is changed
machine.succeed(": > /run/secrets/rendered/test_template")
# The template is changed, now something should happen
machine.succeed("/run/current-system/bin/switch-to-configuration test")
# Ensure something happened
machine.succeed("test -f /restarted")
machine.succeed("test -f /reloaded")
# Cleanup the marker files.
machine.succeed("rm /restarted /reloaded")
with subtest("change detection"):
machine.succeed("rm /run/secrets/test_key")
machine.succeed("rm /run/secrets/rendered/test_template")
out = machine.succeed("/run/current-system/bin/switch-to-configuration test")
assertOutput(
out,
"adding secret: test_key",
"adding rendered secret: test_template",
)
machine.succeed(": > /run/secrets/test_key")
machine.succeed(": > /run/secrets/rendered/test_template")
out = machine.succeed("/run/current-system/bin/switch-to-configuration test")
assertOutput(
out,
"modifying secret: test_key",
"modifying rendered secret: test_template",
)
machine.succeed(": > /run/secrets/another_key")
machine.succeed(": > /run/secrets/rendered/another_template")
out = machine.succeed("/run/current-system/bin/switch-to-configuration test")
assertOutput(
out,
"removing secret: another_key",
"removing rendered secret: another_template",
)
with subtest("dry activation"):
machine.succeed("rm /run/secrets/test_key")
machine.succeed("rm /run/secrets/rendered/test_template")
machine.succeed(": > /run/secrets/another_key")
machine.succeed(": > /run/secrets/rendered/another_template")
out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate")
assertOutput(
out,
"would add secret: test_key",
"would remove secret: another_key",
"would add rendered secret: test_template",
"would remove rendered secret: another_template",
)
# Verify that we did not actually activate the new configuration.
machine.fail("test -f /run/secrets/test_key")
machine.fail("test -f /run/secrets/rendered/test_template")
machine.succeed("test -f /run/secrets/another_key")
machine.succeed("test -f /run/secrets/rendered/another_template")
# Now actually activate and sanity check the resulting secrets.
machine.succeed("/run/current-system/bin/switch-to-configuration test")
machine.succeed("test -f /run/secrets/test_key")
machine.succeed("test -f /run/secrets/rendered/test_template")
machine.fail("test -f /run/secrets/another_key")
machine.fail("test -f /run/secrets/rendered/another_template")
# Remove the restarted/reloaded indicators so we can confirm a
# dry-activate doesn't trigger systemd units.
machine.succeed("rm /restarted /reloaded")
machine.succeed(": > /run/secrets/test_key")
out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate")
assertOutput(
out,
"would modify secret: test_key",
)
machine.succeed("[ $(cat /run/secrets/test_key | wc -c) = 0 ]")
machine.fail("test -f /restarted") # not done in dry mode
machine.fail("test -f /reloaded") # not done in dry mode
'';
};
user-passwords = userPasswordTest "sops-user-passwords" {
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${testAssets + "/age-keys.txt"} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
};
}
// lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.05") {
user-passwords-sysusers = userPasswordTest "sops-user-passwords-sysusers" (
{ pkgs, ... }:
{
systemd.sysusers.enable = true;
users.mutableUsers = true;
system.etc.overlay.enable = true;
boot.initrd.systemd.enable = true;
boot.kernelPackages = pkgs.linuxPackages_latest;
# must run before sops sets up keys
systemd.services."sops-install-secrets-for-users".preStart = ''
printf '${builtins.readFile (testAssets + "/age-keys.txt")}' > /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
}
);
}
// lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.11") {
user-passwords-userborn = userPasswordTest "sops-user-passwords-userborn" (
{ pkgs, ... }:
{
services.userborn.enable = true;
users.mutableUsers = false;
system.etc.overlay.enable = true;
boot.initrd.systemd.enable = true;
boot.kernelPackages = pkgs.linuxPackages_latest;
# must run before sops sets up keys
systemd.services."sops-install-secrets-for-users".preStart = ''
printf '${builtins.readFile (testAssets + "/age-keys.txt")}' > /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
}
);
}

View file

@ -1,18 +1,17 @@
{ pkgs ? import <nixpkgs> {}
, vendorHash ? "sha256-kFDRjAqUOcTma5qLQz9YKRfP85A1Z9AXm/jThssP5wU="
}: let
{
pkgs ? import <nixpkgs> { },
vendorHash ? "sha256-7xnbw5tH3MYD/aA8yBNG327IONjUoBarTluLeqTH/8A=",
}:
let
sops-install-secrets = pkgs.callPackage ./pkgs/sops-install-secrets {
inherit vendorHash;
};
in rec {
in
rec {
inherit sops-install-secrets;
sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key {};
sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key { };
default = sops-init-gpg-key;
sops-pgp-hook = pkgs.lib.warn ''
sops-pgp-hook is deprecated, use sops-import-keys-hook instead.
Also see https://github.com/Mic92/sops-nix/issues/98
'' pkgs.callPackage ./pkgs/sops-pgp-hook { };
sops-import-keys-hook = pkgs.callPackage ./pkgs/sops-import-keys-hook { };
# backwards compatibility
@ -22,8 +21,9 @@ in rec {
sops-pgp-hook-test = pkgs.callPackage ./pkgs/sops-pgp-hook-test.nix {
inherit vendorHash;
};
unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix {};
} // (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux {
unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix { };
}
// (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux {
lint = pkgs.callPackage ./pkgs/lint.nix {
inherit sops-install-secrets;
};

1
dev/private.narHash Normal file
View file

@ -0,0 +1 @@
sha256-rXlTQPa9c8Ou52KO5S36sOyKUzurr5fuZcXnHr7g6YY=

90
dev/private/flake.lock generated Normal file
View file

@ -0,0 +1,90 @@
{
"nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs-stable"
]
},
"locked": {
"lastModified": 1731832479,
"narHash": "sha256-icDDuYwJ0avTMZTxe1qyU/Baht5JOqw4pb5mWpR+hT0=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "5056a1cf0ce7c2a08ab50713b6c4af77975f6111",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nix-darwin": {
"inputs": {
"nixpkgs": [
"nixpkgs-stable"
]
},
"locked": {
"lastModified": 1731809072,
"narHash": "sha256-pOsDJQR0imnFLfpvTmRpHcP0tflyxtP/QIzokrKSP8U=",
"owner": "LnL7",
"repo": "nix-darwin",
"rev": "34588d57cfc41c6953c54c93b6b685cab3b548ee",
"type": "github"
},
"original": {
"owner": "LnL7",
"repo": "nix-darwin",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1731842749,
"narHash": "sha256-aNc8irVBH7sM5cGDvqdOueg8S+fGakf0rEMRGfGwWZw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bf6132dc791dbdff8b6894c3a85eb27ad8255682",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "release-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nix-darwin": "nix-darwin",
"nixpkgs-stable": "nixpkgs-stable",
"treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs-stable"
]
},
"locked": {
"lastModified": 1730321837,
"narHash": "sha256-vK+a09qq19QNu2MlLcvN4qcRctJbqWkX7ahgPZ/+maI=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "746901bb8dba96d154b66492a29f5db0693dbfcc",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

15
dev/private/flake.nix Normal file
View file

@ -0,0 +1,15 @@
{
description = "private inputs";
inputs.nixpkgs-stable.url = "github:NixOS/nixpkgs/release-24.05";
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";
inputs.treefmt-nix.inputs.nixpkgs.follows = "nixpkgs-stable";
inputs.nix-darwin.url = "github:LnL7/nix-darwin";
inputs.nix-darwin.inputs.nixpkgs.follows = "nixpkgs-stable";
inputs.home-manager.url = "github:nix-community/home-manager";
inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs-stable";
outputs = _: { };
}

25
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1725534445,
"narHash": "sha256-Yd0FK9SkWy+ZPuNqUgmVPXokxDgMJoGuNpMEtkfcf84=",
"lastModified": 1731763621,
"narHash": "sha256-ddcX4lQL0X05AYkrkV2LMFgGdRvgap7Ho8kgon3iWZk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9bb1e7571aadf31ddb4af77fc64b2d59580f9a39",
"rev": "c69a9bffbecde46b4b939465422ddc59493d3e4d",
"type": "github"
},
"original": {
@ -16,26 +16,9 @@
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1725762081,
"narHash": "sha256-vNv+aJUW5/YurRy1ocfvs4q/48yVESwlC/yHzjkZSP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "dc454045f5b5d814e5862a6d057e7bb5c29edc05",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "release-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"nixpkgs-stable": "nixpkgs-stable"
"nixpkgs": "nixpkgs"
}
}
},

202
flake.nix
View file

@ -1,57 +1,155 @@
{
description = "Integrates sops into nixos";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.nixpkgs-stable.url = "github:NixOS/nixpkgs/release-24.05";
nixConfig.extra-substituters = ["https://cache.thalheim.io"];
nixConfig.extra-trusted-public-keys = ["cache.thalheim.io-1:R7msbosLEZKrxk/lKxf9BTjOOH7Ax3H0Qj0/6wiHOgc="];
outputs = {
self,
nixpkgs,
nixpkgs-stable
}: let
systems = [
"x86_64-linux"
"x86_64-darwin"
"aarch64-darwin"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
suffix-version = version: attrs: nixpkgs.lib.mapAttrs' (name: value: nixpkgs.lib.nameValuePair (name + version) value) attrs;
suffix-stable = suffix-version "-24_05";
in {
overlays.default = final: prev: let
localPkgs = import ./default.nix {pkgs = final;};
in {
inherit (localPkgs) sops-install-secrets sops-init-gpg-key sops-pgp-hook sops-import-keys-hook sops-ssh-to-age;
# backward compatibility
inherit (prev) ssh-to-pgp;
};
nixosModules = {
sops = import ./modules/sops;
default = self.nixosModules.sops;
};
homeManagerModules.sops = import ./modules/home-manager/sops.nix;
homeManagerModule = self.homeManagerModules.sops;
packages = forAllSystems (system:
import ./default.nix {
pkgs = import nixpkgs {inherit system;};
});
checks = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"]
(system: let
tests = self.packages.${system}.sops-install-secrets.tests;
packages-stable = import ./default.nix {
pkgs = import nixpkgs-stable {inherit system;};
};
tests-stable = packages-stable.sops-install-secrets.tests;
in tests //
(suffix-stable tests-stable) //
(suffix-stable packages-stable));
devShells = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix {};
default = pkgs.callPackage ./shell.nix {};
});
};
nixConfig.extra-substituters = [ "https://cache.thalheim.io" ];
nixConfig.extra-trusted-public-keys = [
"cache.thalheim.io-1:R7msbosLEZKrxk/lKxf9BTjOOH7Ax3H0Qj0/6wiHOgc="
];
outputs =
{
self,
nixpkgs,
}@inputs:
let
loadPrivateFlake =
path:
let
flakeHash = builtins.readFile "${toString path}.narHash";
flakePath = "path:${toString path}?narHash=${flakeHash}";
in
builtins.getFlake (builtins.unsafeDiscardStringContext flakePath);
privateFlake = loadPrivateFlake ./dev/private;
privateInputs = privateFlake.inputs;
systems = [
"x86_64-linux"
"x86_64-darwin"
"aarch64-darwin"
"aarch64-linux"
];
eachSystem =
f:
builtins.listToAttrs (
builtins.map (system: {
name = system;
value = f {
pkgs = inputs.nixpkgs.legacyPackages.${system};
inherit system;
};
}) systems
);
in
# public outputs
{
overlays.default =
final: prev:
let
localPkgs = import ./default.nix { pkgs = final; };
in
{
inherit (localPkgs)
sops-install-secrets
sops-init-gpg-key
sops-pgp-hook
sops-import-keys-hook
sops-ssh-to-age
;
# backward compatibility
inherit (prev) ssh-to-pgp;
};
nixosModules = {
sops = ./modules/sops;
default = self.nixosModules.sops;
};
homeManagerModules.sops = ./modules/home-manager/sops.nix;
homeManagerModule = self.homeManagerModules.sops;
darwinModules = {
sops = ./modules/nix-darwin;
default = self.darwinModules.sops;
};
packages = eachSystem ({ pkgs, ... }: import ./default.nix { inherit pkgs; });
}
//
# dev outputs
{
checks = eachSystem (
{ pkgs, system, ... }:
let
packages-stable = import ./default.nix {
pkgs = privateInputs.nixpkgs-stable.legacyPackages.${system};
};
dropOverride = attrs: nixpkgs.lib.removeAttrs attrs [ "override" ];
tests = dropOverride (pkgs.callPackage ./checks/nixos-test.nix { });
tests-stable = dropOverride (
privateInputs.nixpkgs-stable.legacyPackages.${system}.callPackage ./checks/nixos-test.nix { }
);
suffix-version =
version: attrs:
nixpkgs.lib.mapAttrs' (name: value: nixpkgs.lib.nameValuePair (name + version) value) attrs;
suffix-stable = suffix-version "-24_05";
in
{
home-manager = self.legacyPackages.${system}.homeConfigurations.sops.activation-script;
}
// (suffix-stable packages-stable)
// nixpkgs.lib.optionalAttrs pkgs.stdenv.isLinux tests
// nixpkgs.lib.optionalAttrs pkgs.stdenv.isLinux (suffix-stable tests-stable)
// nixpkgs.lib.optionalAttrs pkgs.stdenv.isDarwin {
darwin-sops =
self.darwinConfigurations."sops-${pkgs.hostPlatform.darwinArch}".config.system.build.toplevel;
}
);
darwinConfigurations.sops-arm64 = privateInputs.nix-darwin.lib.darwinSystem {
modules = [
./checks/darwin.nix
{ nixpkgs.hostPlatform = "aarch64-darwin"; }
];
};
darwinConfigurations.sops-x86_64 = privateInputs.nix-darwin.lib.darwinSystem {
modules = [
./checks/darwin.nix
{ nixpkgs.hostPlatform = "x86_64-darwin"; }
];
};
legacyPackages = eachSystem (
{ pkgs, ... }:
{
homeConfigurations.sops = privateInputs.home-manager.lib.homeManagerConfiguration {
modules = [
./checks/home-manager.nix
];
inherit pkgs;
};
}
);
apps = eachSystem (
{ pkgs, ... }:
{
update-dev-private-narHash = {
type = "app";
program = "${pkgs.writeShellScript "update-dev-private-narHash" ''
nix --extra-experimental-features "nix-command flakes" flake lock ./dev/private
nix --extra-experimental-features "nix-command flakes" hash path ./dev/private | tr -d '\n' > ./dev/private.narHash
''}";
};
}
);
devShells = eachSystem (
{ pkgs, ... }:
{
unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix { };
default = pkgs.callPackage ./shell.nix { };
}
);
};
}

12
go.mod
View file

@ -4,12 +4,12 @@ go 1.18
require (
github.com/Mic92/ssh-to-age v0.0.0-20240115094500-460a2109aaf0
github.com/ProtonMail/go-crypto v1.1.0-alpha.5-proton
github.com/ProtonMail/go-crypto v1.1.3
github.com/getsops/sops/v3 v3.8.1
github.com/joho/godotenv v1.5.1
github.com/mozilla-services/yaml v0.0.0-20201007153854-c369669a6625
golang.org/x/crypto v0.27.0
golang.org/x/sys v0.25.0
golang.org/x/crypto v0.29.0
golang.org/x/sys v0.27.0
gopkg.in/ini.v1 v1.67.0
)
@ -90,9 +90,9 @@ require (
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.17.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/term v0.26.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/api v0.167.0 // indirect
google.golang.org/appengine v1.6.8 // indirect

24
go.sum
View file

@ -32,8 +32,8 @@ github.com/Mic92/ssh-to-age v0.0.0-20240115094500-460a2109aaf0 h1:zF3WQbETL3cLvt
github.com/Mic92/ssh-to-age v0.0.0-20240115094500-460a2109aaf0/go.mod h1:OUOla4dJLQ5FfdB07jnjawnMEqI0M3Q4WuD2W/DjhLo=
github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/ProtonMail/go-crypto v1.1.0-alpha.5-proton h1:KVBEgU3CJpmzLChnLiSuEyCuhGhcMt3eOST+7A+ckto=
github.com/ProtonMail/go-crypto v1.1.0-alpha.5-proton/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/aws/aws-sdk-go-v2 v1.25.2 h1:/uiG1avJRgLGiQM9X3qJM8+Qa6KRGK5rRPuXE0HUM+w=
github.com/aws/aws-sdk-go-v2 v1.25.2/go.mod h1:Evoc5AsmtveRt1komDwIsjHFyrP5tDuF1D1U+6z6pNo=
github.com/aws/aws-sdk-go-v2/config v1.27.4 h1:AhfWb5ZwimdsYTgP7Od8E9L1u4sKmDW2ZVeLcf2O42M=
@ -240,8 +240,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -270,8 +270,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -288,15 +288,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@ -304,8 +304,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -1,66 +1,81 @@
{ config, lib, pkgs, ... }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.sops;
sops-install-secrets = (pkgs.callPackage ../.. {}).sops-install-secrets;
secretType = lib.types.submodule ({ config, name, ... }: {
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
description = ''
Name of the file used in /run/user/*/secrets
'';
};
sops-install-secrets = (pkgs.callPackage ../.. { }).sops-install-secrets;
secretType = lib.types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = lib.types.str;
default = name;
description = ''
Name of the file used in /run/user/*/secrets
'';
};
key = lib.mkOption {
type = lib.types.str;
default = name;
description = ''
Key used to lookup in the sops file.
No tested data structures are supported right now.
This option is ignored if format is binary.
'';
};
key = lib.mkOption {
type = lib.types.str;
default = if cfg.defaultSopsKey != null then cfg.defaultSopsKey else name;
description = ''
Key used to lookup in the sops file.
No tested data structures are supported right now.
This option is ignored if format is binary.
"" means whole file.
'';
};
path = lib.mkOption {
type = lib.types.str;
default = "${cfg.defaultSymlinkPath}/${name}";
description = ''
Path where secrets are symlinked to.
If the default is kept no other symlink is created.
`%r` is replaced by $XDG_RUNTIME_DIR on linux or `getconf
DARWIN_USER_TEMP_DIR` on darwin.
'';
};
path = lib.mkOption {
type = lib.types.str;
default = "${cfg.defaultSymlinkPath}/${name}";
description = ''
Path where secrets are symlinked to.
If the default is kept no other symlink is created.
`%r` is replaced by $XDG_RUNTIME_DIR on linux or `getconf
DARWIN_USER_TEMP_DIR` on darwin.
'';
};
format = lib.mkOption {
type = lib.types.enum [ "yaml" "json" "binary" "ini" "dotenv" ];
default = cfg.defaultSopsFormat;
description = ''
File format used to decrypt the sops secret.
Binary files are written to the target file as is.
'';
};
format = lib.mkOption {
type = lib.types.enum [
"yaml"
"json"
"binary"
"ini"
"dotenv"
];
default = cfg.defaultSopsFormat;
description = ''
File format used to decrypt the sops secret.
Binary files are written to the target file as is.
'';
};
mode = lib.mkOption {
type = lib.types.str;
default = "0400";
description = ''
Permissions mode of the in octal.
'';
};
mode = lib.mkOption {
type = lib.types.str;
default = "0400";
description = ''
Permissions mode of the in octal.
'';
};
sopsFile = lib.mkOption {
type = lib.types.path;
default = cfg.defaultSopsFile;
defaultText = "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
sopsFile = lib.mkOption {
type = lib.types.path;
default = cfg.defaultSopsFile;
defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
};
};
};
});
}
);
pathNotInStore = lib.mkOptionType {
name = "pathNotInStore";
@ -70,50 +85,62 @@ let
merge = lib.mergeEqualOption;
};
manifestFor = suffix: secrets: pkgs.writeTextFile {
name = "manifest${suffix}.json";
text = builtins.toJSON {
secrets = builtins.attrValues secrets;
secretsMountPoint = cfg.defaultSecretsMountPoint;
symlinkPath = cfg.defaultSymlinkPath;
keepGenerations = cfg.keepGenerations;
gnupgHome = cfg.gnupg.home;
sshKeyPaths = cfg.gnupg.sshKeyPaths;
ageKeyFile = cfg.age.keyFile;
ageSshKeyPaths = cfg.age.sshKeyPaths;
userMode = true;
logging = {
keyImport = builtins.elem "keyImport" cfg.log;
secretChanges = builtins.elem "secretChanges" cfg.log;
manifestFor =
suffix: secrets: templates:
pkgs.writeTextFile {
name = "manifest${suffix}.json";
text = builtins.toJSON {
secrets = builtins.attrValues secrets;
templates = builtins.attrValues templates;
secretsMountPoint = cfg.defaultSecretsMountPoint;
symlinkPath = cfg.defaultSymlinkPath;
keepGenerations = cfg.keepGenerations;
gnupgHome = cfg.gnupg.home;
sshKeyPaths = cfg.gnupg.sshKeyPaths;
ageKeyFile = cfg.age.keyFile;
ageSshKeyPaths = cfg.age.sshKeyPaths;
userMode = true;
logging = {
keyImport = builtins.elem "keyImport" cfg.log;
secretChanges = builtins.elem "secretChanges" cfg.log;
};
};
checkPhase = ''
${sops-install-secrets}/bin/sops-install-secrets -check-mode=${
if cfg.validateSopsFiles then "sopsfile" else "manifest"
} "$out"
'';
};
checkPhase = ''
${sops-install-secrets}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out"
'';
};
manifest = manifestFor "" cfg.secrets;
manifest = manifestFor "" cfg.secrets cfg.templates;
escapedAgeKeyFile = lib.escapeShellArg cfg.age.keyFile;
script = toString (pkgs.writeShellScript "sops-nix-user" ((lib.optionalString (cfg.gnupg.home != null) ''
export SOPS_GPG_EXEC=${pkgs.gnupg}/bin/gpg
'')
+ (lib.optionalString cfg.age.generateKey ''
if [[ ! -f ${escapedAgeKeyFile} ]]; then
echo generating machine-specific age key...
${pkgs.coreutils}/bin/mkdir -p $(${pkgs.coreutils}/bin/dirname ${escapedAgeKeyFile})
# age-keygen sets 0600 by default, no need to chmod.
${pkgs.age}/bin/age-keygen -o ${escapedAgeKeyFile}
fi
'' + ''
${sops-install-secrets}/bin/sops-install-secrets -ignore-passwd ${manifest}
'')));
in {
script = toString (
pkgs.writeShellScript "sops-nix-user" (
lib.optionalString cfg.age.generateKey ''
if [[ ! -f ${escapedAgeKeyFile} ]]; then
echo generating machine-specific age key...
${pkgs.coreutils}/bin/mkdir -p $(${pkgs.coreutils}/bin/dirname ${escapedAgeKeyFile})
# age-keygen sets 0600 by default, no need to chmod.
${pkgs.age}/bin/age-keygen -o ${escapedAgeKeyFile}
fi
''
+ ''
${sops-install-secrets}/bin/sops-install-secrets -ignore-passwd ${manifest}
''
)
);
in
{
imports = [
./templates.nix
];
options.sops = {
secrets = lib.mkOption {
type = lib.types.attrsOf secretType;
default = {};
default = { };
description = ''
Secrets to decrypt.
'';
@ -134,6 +161,16 @@ in {
'';
};
defaultSopsKey = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Default key used to lookup in all secrets.
This option is ignored if format is binary.
"" means whole file.
'';
};
validateSopsFiles = lib.mkOption {
type = lib.types.bool;
default = true;
@ -169,11 +206,29 @@ in {
};
log = lib.mkOption {
type = lib.types.listOf (lib.types.enum [ "keyImport" "secretChanges" ]);
default = [ "keyImport" "secretChanges" ];
type = lib.types.listOf (
lib.types.enum [
"keyImport"
"secretChanges"
]
);
default = [
"keyImport"
"secretChanges"
];
description = "What to log";
};
environment = lib.mkOption {
type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path);
default = { };
description = ''
Environment variables to set before calling sops-install-secrets.
To properly quote strings with quotes use lib.escapeShellArg.
'';
};
age = {
keyFile = lib.mkOption {
type = lib.types.nullOr pathNotInStore;
@ -196,7 +251,7 @@ in {
sshKeyPaths = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [];
default = [ ];
description = ''
Paths to ssh keys added as age keys during sops description.
'';
@ -213,9 +268,22 @@ in {
'';
};
qubes-split-gpg = {
enable = lib.mkEnableOption "Enable support for Qubes Split GPG feature: https://www.qubes-os.org/doc/split-gpg";
domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "vault-gpg";
description = ''
It tells Qubes OS which secure Qube holds your GPG keys for isolated cryptographic operations.
'';
};
};
sshKeyPaths = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [];
default = [ ];
description = ''
Path to ssh keys added as GPG keys during sops description.
This option must be explicitly unset if <literal>config.sops.gnupg.sshKeyPaths</literal> is set.
@ -224,24 +292,73 @@ in {
};
};
config = lib.mkIf (cfg.secrets != {}) {
assertions = [{
assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != [];
message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home";
} {
assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []);
message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set";
}] ++ lib.optionals cfg.validateSopsFiles (
lib.concatLists (lib.mapAttrsToList (name: secret: [{
assertion = builtins.pathExists secret.sopsFile;
message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
} {
assertion =
builtins.isPath secret.sopsFile ||
(builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
}]) cfg.secrets)
);
config = lib.mkIf (cfg.secrets != { }) {
assertions =
[
{
assertion =
cfg.gnupg.home != null
|| cfg.gnupg.sshKeyPaths != [ ]
|| cfg.gnupg.qubes-split-gpg.enable == true
|| cfg.age.keyFile != null
|| cfg.age.sshKeyPaths != [ ];
message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home or sops.gnupg.qubes-split-gpg.enable";
}
{
assertion =
!(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != [ ])
&& !(cfg.gnupg.home != null && cfg.gnupg.qubes-split-gpg.enable == true)
&& !(cfg.gnupg.sshKeyPaths != [ ] && cfg.gnupg.qubes-split-gpg.enable == true);
message = "Exactly one of sops.gnupg.home, sops.gnupg.qubes-split-gpg.enable and sops.gnupg.sshKeyPaths must be set";
}
{
assertion =
cfg.gnupg.qubes-split-gpg.enable == false
|| (
cfg.gnupg.qubes-split-gpg.enable == true
&& cfg.gnupg.qubes-split-gpg.domain != null
&& cfg.gnupg.qubes-split-gpg.domain != ""
);
message = "sops.gnupg.qubes-split-gpg.domain is required when sops.gnupg.qubes-split-gpg.enable is set to true";
}
]
++ lib.optionals cfg.validateSopsFiles (
lib.concatLists (
lib.mapAttrsToList (name: secret: [
{
assertion = builtins.pathExists secret.sopsFile;
message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
}
{
assertion =
builtins.isPath secret.sopsFile
|| (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
}
]) cfg.secrets
)
);
home.sessionVariables = lib.mkIf cfg.gnupg.qubes-split-gpg.enable {
# TODO: Add this package to nixpkgs and use it from the store
# https://github.com/QubesOS/qubes-app-linux-split-gpg
SOPS_GPG_EXEC = "qubes-gpg-client-wrapper";
};
sops.environment = {
SOPS_GPG_EXEC = lib.mkMerge [
(lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [ ]) (
lib.mkDefault "${pkgs.gnupg}/bin/gpg"
))
(lib.mkIf cfg.gnupg.qubes-split-gpg.enable (
lib.mkDefault config.home.sessionVariables.SOPS_GPG_EXEC
))
];
QUBES_GPG_DOMAIN = lib.mkIf cfg.gnupg.qubes-split-gpg.enable (
lib.mkDefault cfg.gnupg.qubes-split-gpg.domain
);
};
systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux {
Unit = {
@ -249,9 +366,13 @@ in {
};
Service = {
Type = "oneshot";
Environment = builtins.concatStringsSep " " (
lib.mapAttrsToList (name: value: "'${name}=${value}'") cfg.environment
);
ExecStart = script;
};
Install.WantedBy = if cfg.gnupg.home != null then [ "graphical-session-pre.target" ] else [ "default.target" ];
Install.WantedBy =
if cfg.gnupg.home != null then [ "graphical-session-pre.target" ] else [ "default.target" ];
};
# Darwin: load secrets once on login
@ -259,6 +380,7 @@ in {
enable = true;
config = {
Program = script;
EnvironmentVariables = cfg.environment;
KeepAlive = false;
RunAtLoad = true;
StandardOutPath = "${config.home.homeDirectory}/Library/Logs/SopsNix/stdout";
@ -267,28 +389,36 @@ in {
};
# [re]load secrets on home-manager activation
home.activation = let
darwin = let
domain-target = "gui/$(id -u ${config.home.username})";
in ''
/bin/launchctl bootout ${domain-target}/org.nix-community.home.sops-nix && true
/bin/launchctl bootstrap ${domain-target} ${config.home.homeDirectory}/Library/LaunchAgents/org.nix-community.home.sops-nix.plist
'';
home.activation =
let
darwin =
let
domain-target = "gui/$(id -u ${config.home.username})";
in
''
/bin/launchctl bootout ${domain-target}/org.nix-community.home.sops-nix && true
/bin/launchctl bootstrap ${domain-target} ${config.home.homeDirectory}/Library/LaunchAgents/org.nix-community.home.sops-nix.plist
'';
linux = let systemctl = config.systemd.user.systemctlPath; in ''
systemdStatus=$(${systemctl} --user is-system-running 2>&1 || true)
linux =
let
systemctl = config.systemd.user.systemctlPath;
in
''
systemdStatus=$(${systemctl} --user is-system-running 2>&1 || true)
if [[ $systemdStatus == 'running' ]]; then
${systemctl} restart --user sops-nix
else
echo "User systemd daemon not running. Probably executed on boot where no manual start/reload is needed."
fi
if [[ $systemdStatus == 'running' || $systemdStatus == 'degraded' ]]; then
${systemctl} restart --user sops-nix
else
echo "User systemd daemon not running. Probably executed on boot where no manual start/reload is needed."
fi
unset systemdStatus
'';
in {
sops-nix = if pkgs.stdenv.isLinux then linux else darwin;
};
unset systemdStatus
'';
in
{
sops-nix = if pkgs.stdenv.isLinux then linux else darwin;
};
};
}

View file

@ -0,0 +1,106 @@
{
config,
pkgs,
lib,
options,
...
}:
let
inherit (lib)
mkOption
mkDefault
mapAttrs
types
;
in
{
options.sops = {
templates = mkOption {
description = "Templates for secret files";
type = types.attrsOf (
types.submodule (
{ config, ... }:
{
options = {
name = mkOption {
type = types.singleLineStr;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets/rendered
'';
};
path = mkOption {
description = "Path where the rendered file will be placed";
type = types.singleLineStr;
# Keep this in sync with `RenderedSubdir` in `pkgs/sops-install-secrets/main.go`
default = "${config.xdg.configHome}/sops-nix/secrets/rendered/${config.name}";
};
content = mkOption {
type = types.lines;
default = "";
description = ''
Content of the file
'';
};
mode = mkOption {
type = types.singleLineStr;
default = "0400";
description = ''
Permissions mode of the rendered secret file in octal.
'';
};
file = mkOption {
type = types.path;
default = pkgs.writeText config.name config.content;
defaultText = lib.literalExpression ''pkgs.writeText config.name config.content'';
example = "./configuration-template.conf";
description = ''
File used as the template. When this value is specified, `sops.templates.<name>.content` is ignored.
'';
};
restartUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be restarted when the rendered template changes.
This works the same way as <xref linkend="opt-systemd.services._name_.restartTriggers" />.
'';
};
reloadUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be reloaded when the rendered template changes.
This works the same way as <xref linkend="opt-systemd.services._name_.reloadTriggers" />.
'';
};
};
}
)
);
default = { };
};
placeholder = mkOption {
type = types.attrsOf (
types.mkOptionType {
name = "coercibleToString";
description = "value that can be coerced to string";
check = lib.strings.isConvertibleWithToString;
merge = lib.mergeEqualOption;
}
);
default = { };
visible = false;
};
};
config = lib.optionalAttrs (options ? sops.secrets) (
lib.mkIf (config.sops.templates != { }) {
sops.placeholder = mapAttrs (
name: _: mkDefault "<SOPS:${builtins.hashString "sha256" name}:PLACEHOLDER>"
) config.sops.secrets;
}
);
}

View file

@ -0,0 +1,401 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.sops;
sops-install-secrets = cfg.package;
manifestFor = pkgs.callPackage ./manifest-for.nix {
inherit cfg;
inherit (pkgs) writeTextFile;
};
manifest = manifestFor "" regularSecrets regularTemplates { };
# Currently, all templates are "regular" (there's no support for `neededForUsers` for templates.)
regularTemplates = cfg.templates;
pathNotInStore = lib.mkOptionType {
name = "pathNotInStore";
description = "path not in the Nix store";
descriptionClass = "noun";
check = x: !lib.path.hasStorePathPrefix (/. + x);
merge = lib.mergeEqualOption;
};
regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets;
withEnvironment = import ./with-environment.nix {
inherit cfg lib;
};
secretType = lib.types.submodule (
{ config, ... }:
{
config = {
sopsFile = lib.mkOptionDefault cfg.defaultSopsFile;
sopsFileHash = lib.mkOptionDefault (
lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}"
);
};
options = {
name = lib.mkOption {
type = lib.types.str;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets
'';
};
key = lib.mkOption {
type = lib.types.str;
default = config._module.args.name;
description = ''
Key used to lookup in the sops file.
No tested data structures are supported right now.
This option is ignored if format is binary.
'';
};
path = lib.mkOption {
type = lib.types.str;
default =
if config.neededForUsers then
"/run/secrets-for-users/${config.name}"
else
"/run/secrets/${config.name}";
defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise.";
description = ''
Path where secrets are symlinked to.
If the default is kept no symlink is created.
'';
};
format = lib.mkOption {
type = lib.types.enum [
"yaml"
"json"
"binary"
"dotenv"
"ini"
];
default = cfg.defaultSopsFormat;
description = ''
File format used to decrypt the sops secret.
Binary files are written to the target file as is.
'';
};
mode = lib.mkOption {
type = lib.types.str;
default = "0400";
description = ''
Permissions mode of the in octal.
'';
};
owner = lib.mkOption {
type = with lib.types; nullOr str;
default = "root";
description = ''
User of the file. Can only be set if uid is 0.
'';
};
uid = lib.mkOption {
type = with lib.types; nullOr int;
default = 0;
description = ''
UID of the file, only applied when owner is null. The UID will be applied even if the corresponding user doesn't exist.
'';
};
group = lib.mkOption {
type = with lib.types; nullOr str;
default = "staff";
defaultText = "staff";
description = ''
Group of the file. Can only be set if gid is 0.
'';
};
gid = lib.mkOption {
type = with lib.types; nullOr int;
default = 0;
description = ''
GID of the file, only applied when group is null. The GID will be applied even if the corresponding group doesn't exist.
'';
};
sopsFile = lib.mkOption {
type = lib.types.path;
defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
};
sopsFileHash = lib.mkOption {
type = lib.types.str;
readOnly = true;
description = ''
Hash of the sops file.
'';
};
neededForUsers = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
**Warning** This option doesn't have any effect on macOS, as nix-darwin cannot manage user passwords on macOS.
This can be used to retrieve user's passwords from sops-nix.
Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root.
'';
};
};
}
);
darwinSSHKeys = [
{
type = "rsa";
path = "/etc/ssh/ssh_host_rsa_key";
}
{
type = "ed25519";
path = "/etc/ssh/ssh_host_ed25519_key";
}
];
escapedKeyFile = lib.escapeShellArg cfg.age.keyFile;
# Skip ssh keys deployed with sops to avoid a catch 22
defaultImportKeys =
algo:
map (e: e.path) (
lib.filter (e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)) darwinSSHKeys
);
installScript = ''
${
if cfg.age.generateKey then
''
if [[ ! -f ${escapedKeyFile} ]]; then
echo generating machine-specific age key...
mkdir -p "$(dirname ${escapedKeyFile})"
# age-keygen sets 0600 by default, no need to chmod.
${pkgs.age}/bin/age-keygen -o ${escapedKeyFile}
fi
''
else
""
}
echo "Setting up secrets..."
${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"}
'';
in
{
options.sops = {
secrets = lib.mkOption {
type = lib.types.attrsOf secretType;
default = { };
description = ''
Path where the latest secrets are mounted to.
'';
};
defaultSopsFile = lib.mkOption {
type = lib.types.path;
description = ''
Default sops file used for all secrets.
'';
};
defaultSopsFormat = lib.mkOption {
type = lib.types.str;
default = "yaml";
description = ''
Default sops format used for all secrets.
'';
};
validateSopsFiles = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Check all sops files at evaluation time.
This requires sops files to be added to the nix store.
'';
};
keepGenerations = lib.mkOption {
type = lib.types.ints.unsigned;
default = 1;
description = ''
Number of secrets generations to keep. Setting this to 0 disables pruning.
'';
};
log = lib.mkOption {
type = lib.types.listOf (
lib.types.enum [
"keyImport"
"secretChanges"
]
);
default = [
"keyImport"
"secretChanges"
];
description = "What to log";
};
environment = lib.mkOption {
type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path);
default = { };
description = ''
Environment variables to set before calling sops-install-secrets.
The values are placed in single quotes and not escaped any further to
allow usage of command substitutions for more flexibility. To properly quote
strings with quotes use lib.escapeShellArg.
This will be evaluated twice when using secrets that use neededForUsers but
in a subshell each time so the environment variables don't collide.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = (pkgs.callPackage ../.. { }).sops-install-secrets;
defaultText = lib.literalExpression "(pkgs.callPackage ../.. {}).sops-install-secrets";
description = ''
sops-install-secrets package to use.
'';
};
validationPackage = lib.mkOption {
type = lib.types.package;
default =
if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then
sops-install-secrets
else
(pkgs.pkgsBuildHost.callPackage ../.. { }).sops-install-secrets;
defaultText = lib.literalExpression "config.sops.package";
description = ''
sops-install-secrets package to use when validating configuration.
Defaults to sops.package if building natively, and a native version of sops-install-secrets if cross compiling.
'';
};
age = {
keyFile = lib.mkOption {
type = lib.types.nullOr pathNotInStore;
default = null;
example = "/var/lib/sops-nix/key.txt";
description = ''
Path to age key file used for sops decryption.
'';
};
generateKey = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether or not to generate the age key. If this
option is set to false, the key must already be
present at the specified location.
'';
};
sshKeyPaths = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = defaultImportKeys "ed25519";
defaultText = lib.literalMD "The ed25519 keys from {option}`config.services.openssh.hostKeys`";
description = ''
Paths to ssh keys added as age keys during sops description.
'';
};
};
gnupg = {
home = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/root/.gnupg";
description = ''
Path to gnupg database directory containing the key for decrypting the sops file.
'';
};
sshKeyPaths = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = defaultImportKeys "rsa";
defaultText = lib.literalMD "The rsa keys from {option}`config.services.openssh.hostKeys`";
description = ''
Path to ssh keys added as GPG keys during sops description.
This option must be explicitly unset if <literal>config.sops.gnupg.home</literal> is set.
'';
};
};
};
imports = [
./templates
./secrets-for-users
];
config = lib.mkMerge [
(lib.mkIf (cfg.secrets != { }) {
assertions =
[
{
assertion =
cfg.gnupg.home != null
|| cfg.gnupg.sshKeyPaths != [ ]
|| cfg.age.keyFile != null
|| cfg.age.sshKeyPaths != [ ];
message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home";
}
{
assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != [ ]);
message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set";
}
]
++ lib.optionals cfg.validateSopsFiles (
lib.concatLists (
lib.mapAttrsToList (name: secret: [
{
assertion = builtins.pathExists secret.sopsFile;
message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
}
{
assertion =
builtins.isPath secret.sopsFile
|| (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
}
{
assertion = secret.uid != null && secret.uid != 0 -> secret.owner == null;
message = "In ${secret.name} exactly one of sops.owner and sops.uid must be set";
}
{
assertion = secret.gid != null && secret.gid != 0 -> secret.group == null;
message = "In ${secret.name} exactly one of sops.group and sops.gid must be set";
}
]) cfg.secrets
)
);
system.build.sops-nix-manifest = manifest;
system.activationScripts = {
postActivation.text = lib.mkAfter installScript;
};
launchd.daemons.sops-install-secrets = {
command = installScript;
serviceConfig = {
RunAtLoad = true;
KeepAlive = false;
};
};
})
{
sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [ ]) (
lib.mkDefault "${pkgs.gnupg}/bin/gpg"
);
}
];
}

View file

@ -0,0 +1,34 @@
{ writeTextFile, cfg }:
suffix: secrets: templates: extraJson:
writeTextFile {
name = "manifest${suffix}.json";
text = builtins.toJSON (
{
secrets = builtins.attrValues secrets;
templates = builtins.attrValues templates;
# Does this need to be configurable?
secretsMountPoint = "/run/secrets.d";
symlinkPath = "/run/secrets";
keepGenerations = cfg.keepGenerations;
gnupgHome = cfg.gnupg.home;
sshKeyPaths = cfg.gnupg.sshKeyPaths;
ageKeyFile = cfg.age.keyFile;
ageSshKeyPaths = cfg.age.sshKeyPaths;
useTmpfs = false;
placeholderBySecretName = cfg.placeholder;
userMode = false;
logging = {
keyImport = builtins.elem "keyImport" cfg.log;
secretChanges = builtins.elem "secretChanges" cfg.log;
};
}
// extraJson
);
checkPhase = ''
${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${
if cfg.validateSopsFiles then "sopsfile" else "manifest"
} "$out"
'';
}

View file

@ -0,0 +1,53 @@
{
lib,
config,
pkgs,
...
}:
let
cfg = config.sops;
secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets;
templatesForUsers = { }; # We do not currently support `neededForUsers` for templates.
manifestFor = pkgs.callPackage ../manifest-for.nix {
inherit cfg;
inherit (pkgs) writeTextFile;
};
withEnvironment = import ../with-environment.nix {
inherit cfg lib;
};
manifestForUsers = manifestFor "-for-users" secretsForUsers templatesForUsers {
secretsMountPoint = "/run/secrets-for-users.d";
symlinkPath = "/run/secrets-for-users";
};
installScript = ''
echo "Setting up secrets for users"
${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"}
'';
in
{
assertions = [
{
assertion =
(lib.filterAttrs (
_: v: (v.uid != 0 && v.owner != "root") || (v.gid != 0 && v.group != "root")
) secretsForUsers) == { };
message = "neededForUsers cannot be used for secrets that are not root-owned";
}
];
system.activationScripts = lib.mkIf (secretsForUsers != [ ]) {
postActivation.text = lib.mkAfter installScript;
};
launchd.daemons.sops-install-secrets-for-users = lib.mkIf (secretsForUsers != [ ]) {
command = installScript;
serviceConfig = {
RunAtLoad = true;
KeepAlive = false;
};
};
system.build.sops-nix-users-manifest = manifestForUsers;
}

View file

@ -0,0 +1,102 @@
{
config,
pkgs,
lib,
options,
...
}:
let
inherit (lib)
mkOption
mkDefault
mapAttrs
types
;
in
{
options.sops = {
templates = mkOption {
description = "Templates for secret files";
type = types.attrsOf (
types.submodule (
{ config, ... }:
{
options = {
name = mkOption {
type = types.singleLineStr;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets/rendered
'';
};
path = mkOption {
description = "Path where the rendered file will be placed";
type = types.singleLineStr;
default = "/run/secrets/rendered/${config.name}";
};
content = mkOption {
type = types.lines;
default = "";
description = ''
Content of the file
'';
};
mode = mkOption {
type = types.singleLineStr;
default = "0400";
description = ''
Permissions mode of the rendered secret file in octal.
'';
};
owner = mkOption {
type = types.singleLineStr;
default = "root";
description = ''
User of the file.
'';
};
group = mkOption {
type = types.singleLineStr;
default = "staff";
defaultText = "staff";
description = ''
Group of the file. Default on darwin in staff.
'';
};
file = mkOption {
type = types.path;
default = pkgs.writeText config.name config.content;
defaultText = lib.literalExpression ''pkgs.writeText config.name config.content'';
example = "./configuration-template.conf";
description = ''
File used as the template. When this value is specified, `sops.templates.<name>.content` is ignored.
'';
};
};
}
)
);
default = { };
};
placeholder = mkOption {
type = types.attrsOf (
types.mkOptionType {
name = "coercibleToString";
description = "value that can be coerced to string";
check = lib.strings.isConvertibleWithToString;
merge = lib.mergeEqualOption;
}
);
default = { };
visible = false;
};
};
config = lib.optionalAttrs (options ? sops.secrets) (
lib.mkIf (config.sops.templates != { }) {
sops.placeholder = mapAttrs (
name: _: mkDefault "<SOPS:${builtins.hashString "sha256" name}:PLACEHOLDER>"
) config.sops.secrets;
}
);
}

View file

@ -0,0 +1,14 @@
{ cfg, lib }:
sopsCall:
if cfg.environment == { } then
sopsCall
else
''
(
# shellcheck disable=SC2030,SC2031
${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)}
${sopsCall}
)
''

View file

@ -1,4 +1,10 @@
{ config, options, lib, pkgs, ... }:
{
config,
options,
lib,
pkgs,
...
}:
let
cfg = config.sops;
@ -8,7 +14,7 @@ let
inherit cfg;
inherit (pkgs) writeTextFile;
};
manifest = manifestFor "" regularSecrets {};
manifest = manifestFor "" regularSecrets regularTemplates { };
pathNotInStore = lib.mkOptionType {
name = "pathNotInStore";
@ -20,128 +26,168 @@ let
regularSecrets = lib.filterAttrs (_: v: !v.neededForUsers) cfg.secrets;
useSystemdActivation = (options.systemd ? sysusers && config.systemd.sysusers.enable) ||
(options.services ? userborn && config.services.userborn.enable);
# Currently, all templates are "regular" (there's no support for `neededForUsers` for templates.)
regularTemplates = cfg.templates;
useSystemdActivation =
(options.systemd ? sysusers && config.systemd.sysusers.enable)
|| (options.services ? userborn && config.services.userborn.enable);
withEnvironment = import ./with-environment.nix {
inherit cfg lib;
};
secretType = lib.types.submodule ({ config, ... }: {
config = {
sopsFile = lib.mkOptionDefault cfg.defaultSopsFile;
sopsFileHash = lib.mkOptionDefault (lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}");
};
options = {
name = lib.mkOption {
type = lib.types.str;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets
'';
secretType = lib.types.submodule (
{ config, ... }:
{
config = {
sopsFile = lib.mkOptionDefault cfg.defaultSopsFile;
sopsFileHash = lib.mkOptionDefault (
lib.optionalString cfg.validateSopsFiles "${builtins.hashFile "sha256" config.sopsFile}"
);
};
key = lib.mkOption {
type = lib.types.str;
default = config._module.args.name;
description = ''
Key used to lookup in the sops file.
No tested data structures are supported right now.
This option is ignored if format is binary.
'';
options = {
name = lib.mkOption {
type = lib.types.str;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets
'';
};
key = lib.mkOption {
type = lib.types.str;
default = if cfg.defaultSopsKey != null then cfg.defaultSopsKey else config._module.args.name;
description = ''
Key used to lookup in the sops file.
No tested data structures are supported right now.
This option is ignored if format is binary.
"" means whole file.
'';
};
path = lib.mkOption {
type = lib.types.str;
default =
if config.neededForUsers then
"/run/secrets-for-users/${config.name}"
else
"/run/secrets/${config.name}";
defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise.";
description = ''
Path where secrets are symlinked to.
If the default is kept no symlink is created.
'';
};
format = lib.mkOption {
type = lib.types.enum [
"yaml"
"json"
"binary"
"dotenv"
"ini"
];
default = cfg.defaultSopsFormat;
description = ''
File format used to decrypt the sops secret.
Binary files are written to the target file as is.
'';
};
mode = lib.mkOption {
type = lib.types.str;
default = "0400";
description = ''
Permissions mode of the in octal.
'';
};
owner = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
description = ''
User of the file. Can only be set if uid is 0.
'';
};
uid = lib.mkOption {
type = with lib.types; nullOr int;
default = 0;
description = ''
UID of the file, only applied when owner is null. The UID will be applied even if the corresponding user doesn't exist.
'';
};
group = lib.mkOption {
type = with lib.types; nullOr str;
default = if config.owner != null then users.${config.owner}.group else null;
defaultText = lib.literalMD "{option}`config.users.users.\${owner}.group`";
description = ''
Group of the file. Can only be set if gid is 0.
'';
};
gid = lib.mkOption {
type = with lib.types; nullOr int;
default = 0;
description = ''
GID of the file, only applied when group is null. The GID will be applied even if the corresponding group doesn't exist.
'';
};
sopsFile = lib.mkOption {
type = lib.types.path;
defaultText = lib.literalExpression "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
};
sopsFileHash = lib.mkOption {
type = lib.types.str;
readOnly = true;
description = ''
Hash of the sops file, useful in <xref linkend="opt-systemd.services._name_.restartTriggers" />.
'';
};
restartUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be restarted when this secret changes.
This works the same way as <xref linkend="opt-systemd.services._name_.restartTriggers" />.
'';
};
reloadUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be reloaded when this secret changes.
This works the same way as <xref linkend="opt-systemd.services._name_.reloadTriggers" />.
'';
};
neededForUsers = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enabling this option causes the secret to be decrypted before users and groups are created.
This can be used to retrieve user's passwords from sops-nix.
Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root.
'';
};
};
path = lib.mkOption {
type = lib.types.str;
default = if config.neededForUsers then "/run/secrets-for-users/${config.name}" else "/run/secrets/${config.name}";
defaultText = "/run/secrets-for-users/$name when neededForUsers is set, /run/secrets/$name when otherwise.";
description = ''
Path where secrets are symlinked to.
If the default is kept no symlink is created.
'';
};
format = lib.mkOption {
type = lib.types.enum ["yaml" "json" "binary" "dotenv" "ini"];
default = cfg.defaultSopsFormat;
description = ''
File format used to decrypt the sops secret.
Binary files are written to the target file as is.
'';
};
mode = lib.mkOption {
type = lib.types.str;
default = "0400";
description = ''
Permissions mode of the in octal.
'';
};
owner = lib.mkOption {
type = lib.types.str;
default = "root";
description = ''
User of the file.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = users.${config.owner}.group;
defaultText = lib.literalMD "{option}`config.users.users.\${owner}.group`";
description = ''
Group of the file.
'';
};
sopsFile = lib.mkOption {
type = lib.types.path;
defaultText = "\${config.sops.defaultSopsFile}";
description = ''
Sops file the secret is loaded from.
'';
};
sopsFileHash = lib.mkOption {
type = lib.types.str;
readOnly = true;
description = ''
Hash of the sops file, useful in <xref linkend="opt-systemd.services._name_.restartTriggers" />.
'';
};
restartUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be restarted when this secret changes.
This works the same way as <xref linkend="opt-systemd.services._name_.restartTriggers" />.
'';
};
reloadUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be reloaded when this secret changes.
This works the same way as <xref linkend="opt-systemd.services._name_.reloadTriggers" />.
'';
};
neededForUsers = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enabling this option causes the secret to be decrypted before users and groups are created.
This can be used to retrieve user's passwords from sops-nix.
Setting this option moves the secret to /run/secrets-for-users and disallows setting owner and group to anything else than root.
'';
};
};
});
}
);
# Skip ssh keys deployed with sops to avoid a catch 22
defaultImportKeys = algo:
defaultImportKeys =
algo:
if config.services.openssh.enable then
map (e: e.path) (lib.filter (e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)) config.services.openssh.hostKeys)
map (e: e.path) (
lib.filter (
e: e.type == algo && !(lib.hasPrefix "/run/secrets" e.path)
) config.services.openssh.hostKeys
)
else
[];
in {
[ ];
in
{
options.sops = {
secrets = lib.mkOption {
type = lib.types.attrsOf secretType;
default = {};
default = { };
description = ''
Path where the latest secrets are mounted to.
'';
@ -162,6 +208,16 @@ in {
'';
};
defaultSopsKey = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Default key used to lookup in all secrets.
This option is ignored if format is binary.
"" means whole file.
'';
};
validateSopsFiles = lib.mkOption {
type = lib.types.bool;
default = true;
@ -180,14 +236,22 @@ in {
};
log = lib.mkOption {
type = lib.types.listOf (lib.types.enum [ "keyImport" "secretChanges" ]);
default = [ "keyImport" "secretChanges" ];
type = lib.types.listOf (
lib.types.enum [
"keyImport"
"secretChanges"
]
);
default = [
"keyImport"
"secretChanges"
];
description = "What to log";
};
environment = lib.mkOption {
type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.path);
default = {};
default = { };
description = ''
Environment variables to set before calling sops-install-secrets.
@ -202,7 +266,7 @@ in {
package = lib.mkOption {
type = lib.types.package;
default = (pkgs.callPackage ../.. {}).sops-install-secrets;
default = (pkgs.callPackage ../.. { }).sops-install-secrets;
defaultText = lib.literalExpression "(pkgs.callPackage ../.. {}).sops-install-secrets";
description = ''
sops-install-secrets package to use.
@ -212,9 +276,10 @@ in {
validationPackage = lib.mkOption {
type = lib.types.package;
default =
if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform
then sops-install-secrets
else (pkgs.pkgsBuildHost.callPackage ../.. {}).sops-install-secrets;
if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then
sops-install-secrets
else
(pkgs.pkgsBuildHost.callPackage ../.. { }).sops-install-secrets;
defaultText = lib.literalExpression "config.sops.package";
description = ''
@ -298,34 +363,78 @@ in {
imports = [
./templates
./secrets-for-users
(lib.mkRenamedOptionModule [ "sops" "gnupgHome" ] [ "sops" "gnupg" "home" ])
(lib.mkRenamedOptionModule [ "sops" "sshKeyPaths" ] [ "sops" "gnupg" "sshKeyPaths" ])
(lib.mkRenamedOptionModule
[
"sops"
"gnupgHome"
]
[
"sops"
"gnupg"
"home"
]
)
(lib.mkRenamedOptionModule
[
"sops"
"sshKeyPaths"
]
[
"sops"
"gnupg"
"sshKeyPaths"
]
)
];
config = lib.mkMerge [
(lib.mkIf (cfg.secrets != {}) {
assertions = [{
assertion = cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [] || cfg.age.keyFile != null || cfg.age.sshKeyPaths != [];
message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home";
} {
assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != []);
message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set";
}] ++ lib.optionals cfg.validateSopsFiles (
lib.concatLists (lib.mapAttrsToList (name: secret: [{
assertion = builtins.pathExists secret.sopsFile;
message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
} {
assertion =
builtins.isPath secret.sopsFile ||
(builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
}]) cfg.secrets)
(lib.mkIf (cfg.secrets != { }) {
assertions =
[
{
assertion =
cfg.gnupg.home != null
|| cfg.gnupg.sshKeyPaths != [ ]
|| cfg.age.keyFile != null
|| cfg.age.sshKeyPaths != [ ];
message = "No key source configured for sops. Either set services.openssh.enable or set sops.age.keyFile or sops.gnupg.home";
}
{
assertion = !(cfg.gnupg.home != null && cfg.gnupg.sshKeyPaths != [ ]);
message = "Exactly one of sops.gnupg.home and sops.gnupg.sshKeyPaths must be set";
}
]
++ lib.optionals cfg.validateSopsFiles (
lib.concatLists (
lib.mapAttrsToList (name: secret: [
{
assertion = builtins.pathExists secret.sopsFile;
message = "Cannot find path '${secret.sopsFile}' set in sops.secrets.${lib.strings.escapeNixIdentifier name}.sopsFile";
}
{
assertion =
builtins.isPath secret.sopsFile
|| (builtins.isString secret.sopsFile && lib.hasPrefix builtins.storeDir secret.sopsFile);
message = "'${secret.sopsFile}' is not in the Nix store. Either add it to the Nix store or set sops.validateSopsFiles to false";
}
{
assertion = secret.uid != null && secret.uid != 0 -> secret.owner == null;
message = "In ${secret.name} exactly one of sops.owner and sops.uid must be set";
}
{
assertion = secret.gid != null && secret.gid != 0 -> secret.group == null;
message = "In ${secret.name} exactly one of sops.group and sops.gid must be set";
}
]) cfg.secrets
)
);
sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != [ ]) (
lib.mkDefault "${pkgs.gnupg}/bin/gpg"
);
sops.environment.SOPS_GPG_EXEC = lib.mkIf (cfg.gnupg.home != null || cfg.gnupg.sshKeyPaths != []) (lib.mkDefault "${pkgs.gnupg}/bin/gpg");
# When using sysusers we no longer be started as an activation script because those are started in initrd while sysusers is started later.
# When using sysusers we no longer are started as an activation script because those are started in initrd while sysusers is started later.
systemd.services.sops-install-secrets = lib.mkIf (regularSecrets != { } && useSystemdActivation) {
wantedBy = [ "sysinit.target" ];
wantedBy = [ "sysinit.target" ];
after = [ "systemd-sysusers.service" ];
environment = cfg.environment;
unitConfig.DefaultDependencies = "no";
@ -338,27 +447,43 @@ in {
};
system.activationScripts = {
setupSecrets = lib.mkIf (regularSecrets != {} && !useSystemdActivation) (lib.stringAfter ([ "specialfs" "users" "groups" ] ++ lib.optional cfg.age.generateKey "generate-age-key") ''
[ -e /run/current-system ] || echo setting up secrets...
${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"}
'' // lib.optionalAttrs (config.system ? dryActivationScript) {
supportsDryActivation = true;
});
setupSecrets = lib.mkIf (regularSecrets != { } && !useSystemdActivation) (
lib.stringAfter
(
[
"specialfs"
"users"
"groups"
]
++ lib.optional cfg.age.generateKey "generate-age-key"
)
''
[ -e /run/current-system ] || echo setting up secrets...
${withEnvironment "${sops-install-secrets}/bin/sops-install-secrets ${manifest}"}
''
// lib.optionalAttrs (config.system ? dryActivationScript) {
supportsDryActivation = true;
}
);
generate-age-key = let
escapedKeyFile = lib.escapeShellArg cfg.age.keyFile;
in lib.mkIf cfg.age.generateKey (lib.stringAfter [] ''
if [[ ! -f ${escapedKeyFile} ]]; then
echo generating machine-specific age key...
mkdir -p $(dirname ${escapedKeyFile})
# age-keygen sets 0600 by default, no need to chmod.
${pkgs.age}/bin/age-keygen -o ${escapedKeyFile}
fi
'');
generate-age-key =
let
escapedKeyFile = lib.escapeShellArg cfg.age.keyFile;
in
lib.mkIf cfg.age.generateKey (
lib.stringAfter [ ] ''
if [[ ! -f ${escapedKeyFile} ]]; then
echo generating machine-specific age key...
mkdir -p $(dirname ${escapedKeyFile})
# age-keygen sets 0600 by default, no need to chmod.
${pkgs.age}/bin/age-keygen -o ${escapedKeyFile}
fi
''
);
};
})
{
system.build.sops-nix-manifest = manifest;
system.build.sops-nix-manifest = manifest;
}
];
}

View file

@ -1,27 +1,34 @@
{ writeTextFile, cfg }:
suffix: secrets: extraJson:
suffix: secrets: templates: extraJson:
writeTextFile {
name = "manifest${suffix}.json";
text = builtins.toJSON ({
secrets = builtins.attrValues secrets;
# Does this need to be configurable?
secretsMountPoint = "/run/secrets.d";
symlinkPath = "/run/secrets";
keepGenerations = cfg.keepGenerations;
gnupgHome = cfg.gnupg.home;
sshKeyPaths = cfg.gnupg.sshKeyPaths;
ageKeyFile = cfg.age.keyFile;
ageSshKeyPaths = cfg.age.sshKeyPaths;
useTmpfs = cfg.useTmpfs;
userMode = false;
logging = {
keyImport = builtins.elem "keyImport" cfg.log;
secretChanges = builtins.elem "secretChanges" cfg.log;
};
} // extraJson);
text = builtins.toJSON (
{
secrets = builtins.attrValues secrets;
templates = builtins.attrValues templates;
# Does this need to be configurable?
secretsMountPoint = "/run/secrets.d";
symlinkPath = "/run/secrets";
keepGenerations = cfg.keepGenerations;
gnupgHome = cfg.gnupg.home;
sshKeyPaths = cfg.gnupg.sshKeyPaths;
ageKeyFile = cfg.age.keyFile;
ageSshKeyPaths = cfg.age.sshKeyPaths;
useTmpfs = cfg.useTmpfs;
placeholderBySecretName = cfg.placeholder;
userMode = false;
logging = {
keyImport = builtins.elem "keyImport" cfg.log;
secretChanges = builtins.elem "secretChanges" cfg.log;
};
}
// extraJson
);
checkPhase = ''
${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${if cfg.validateSopsFiles then "sopsfile" else "manifest"} "$out"
${cfg.validationPackage}/bin/sops-install-secrets -check-mode=${
if cfg.validateSopsFiles then "sopsfile" else "manifest"
} "$out"
'';
}

View file

@ -1,7 +1,14 @@
{ lib, options, config, pkgs, ... }:
{
lib,
options,
config,
pkgs,
...
}:
let
cfg = config.sops;
secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets;
templatesForUsers = { }; # We do not currently support `neededForUsers` for templates.
manifestFor = pkgs.callPackage ../manifest-for.nix {
inherit cfg;
inherit (pkgs) writeTextFile;
@ -9,49 +16,59 @@ let
withEnvironment = import ../with-environment.nix {
inherit cfg lib;
};
manifestForUsers = manifestFor "-for-users" secretsForUsers {
manifestForUsers = manifestFor "-for-users" secretsForUsers templatesForUsers {
secretsMountPoint = "/run/secrets-for-users.d";
symlinkPath = "/run/secrets-for-users";
};
sysusersEnabled = options.systemd ? sysusers && config.systemd.sysusers.enable;
useSystemdActivation = sysusersEnabled ||
(options.services ? userborn && config.services.userborn.enable);
useSystemdActivation =
sysusersEnabled || (options.services ? userborn && config.services.userborn.enable);
in
{
systemd.services.sops-install-secrets-for-users = lib.mkIf (secretsForUsers != { } && useSystemdActivation) {
wantedBy = [ "systemd-sysusers.service" ];
before = [ "systemd-sysusers.service" ];
environment = cfg.environment;
unitConfig.DefaultDependencies = "no";
systemd.services.sops-install-secrets-for-users =
lib.mkIf (secretsForUsers != { } && useSystemdActivation)
{
wantedBy = [ "systemd-sysusers.service" ];
before = [ "systemd-sysusers.service" ];
environment = cfg.environment;
unitConfig.DefaultDependencies = "no";
serviceConfig = {
Type = "oneshot";
ExecStart = [ "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}" ];
RemainAfterExit = true;
};
};
serviceConfig = {
Type = "oneshot";
ExecStart = [ "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}" ];
RemainAfterExit = true;
};
};
system.activationScripts = lib.mkIf (secretsForUsers != { } && !useSystemdActivation) {
setupSecretsForUsers = lib.stringAfter ([ "specialfs" ] ++ lib.optional cfg.age.generateKey "generate-age-key") ''
[ -e /run/current-system ] || echo setting up secrets for users...
${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"}
'' // lib.optionalAttrs (config.system ? dryActivationScript) {
supportsDryActivation = true;
};
setupSecretsForUsers =
lib.stringAfter ([ "specialfs" ] ++ lib.optional cfg.age.generateKey "generate-age-key") ''
[ -e /run/current-system ] || echo setting up secrets for users...
${withEnvironment "${cfg.package}/bin/sops-install-secrets -ignore-passwd ${manifestForUsers}"}
''
// lib.optionalAttrs (config.system ? dryActivationScript) {
supportsDryActivation = true;
};
users.deps = [ "setupSecretsForUsers" ];
};
assertions = [{
assertion = (lib.filterAttrs (_: v: v.owner != "root" || v.group != "root") secretsForUsers) == { };
message = "neededForUsers cannot be used for secrets that are not root-owned";
} {
assertion = secretsForUsers != { } && sysusersEnabled -> config.users.mutableUsers;
message = ''
systemd.sysusers.enable in combination with sops.secrets.<name>.neededForUsers can only work with config.users.mutableUsers enabled.
See https://github.com/Mic92/sops-nix/issues/475
'';
}];
assertions = [
{
assertion =
(lib.filterAttrs (
_: v: (v.uid != 0 && v.owner != "root") || (v.gid != 0 && v.group != "root")
) secretsForUsers) == { };
message = "neededForUsers cannot be used for secrets that are not root-owned";
}
{
assertion = secretsForUsers != { } && sysusersEnabled -> config.users.mutableUsers;
message = ''
systemd.sysusers.enable in combination with sops.secrets.<name>.neededForUsers can only work with config.users.mutableUsers enabled.
See https://github.com/Mic92/sops-nix/issues/475
'';
}
];
system.build.sops-nix-users-manifest = manifestForUsers;
}

View file

@ -1,109 +1,123 @@
{ config, pkgs, lib, options, ... }:
with lib;
with lib.types;
with builtins;
{
config,
pkgs,
lib,
options,
...
}:
let
cfg = config.sops;
secretsForUsers = lib.filterAttrs (_: v: v.neededForUsers) cfg.secrets;
inherit (lib)
mkOption
mkDefault
mapAttrs
types
;
users = config.users.users;
in {
in
{
options.sops = {
templates = mkOption {
description = "Templates for secret files";
type = attrsOf (submodule ({ config, ... }: {
options = {
name = mkOption {
type = singleLineStr;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets-rendered
'';
};
path = mkOption {
description = "Path where the rendered file will be placed";
type = singleLineStr;
default = "/run/secrets-rendered/${config.name}";
};
content = mkOption {
type = lines;
default = "";
description = ''
Content of the file
'';
};
mode = mkOption {
type = singleLineStr;
default = "0400";
description = ''
Permissions mode of the rendered secret file in octal.
'';
};
owner = mkOption {
type = singleLineStr;
default = "root";
description = ''
User of the file.
'';
};
group = mkOption {
type = singleLineStr;
default = users.${config.owner}.group;
defaultText = ''config.users.users.''${cfg.owner}.group'';
description = ''
Group of the file.
'';
};
file = mkOption {
type = types.path;
default = pkgs.writeText config.name config.content;
defaultText = ''pkgs.writeText config.name config.content'';
example = "./configuration-template.conf";
description = ''
File used as the template. When this value is specified, `sops.templates.<name>.content` is ignored.
'';
};
};
}));
type = types.attrsOf (
types.submodule (
{ config, ... }:
{
options = {
name = mkOption {
type = types.singleLineStr;
default = config._module.args.name;
description = ''
Name of the file used in /run/secrets/rendered
'';
};
path = mkOption {
description = "Path where the rendered file will be placed";
type = types.singleLineStr;
# Keep this in sync with `RenderedSubdir` in `pkgs/sops-install-secrets/main.go`
default = "/run/secrets/rendered/${config.name}";
};
content = mkOption {
type = types.lines;
default = "";
description = ''
Content of the file
'';
};
mode = mkOption {
type = types.singleLineStr;
default = "0400";
description = ''
Permissions mode of the rendered secret file in octal.
'';
};
owner = mkOption {
type = types.singleLineStr;
default = "root";
description = ''
User of the file.
'';
};
group = mkOption {
type = types.singleLineStr;
default = users.${config.owner}.group;
defaultText = lib.literalExpression ''config.users.users.''${cfg.owner}.group'';
description = ''
Group of the file.
'';
};
file = mkOption {
type = types.path;
default = pkgs.writeText config.name config.content;
defaultText = lib.literalExpression ''pkgs.writeText config.name config.content'';
example = "./configuration-template.conf";
description = ''
File used as the template. When this value is specified, `sops.templates.<name>.content` is ignored.
'';
};
restartUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be restarted when the rendered template changes.
This works the same way as <xref linkend="opt-systemd.services._name_.restartTriggers" />.
'';
};
reloadUnits = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "sshd.service" ];
description = ''
Names of units that should be reloaded when the rendered template changes.
This works the same way as <xref linkend="opt-systemd.services._name_.reloadTriggers" />.
'';
};
};
}
)
);
default = { };
};
placeholder = mkOption {
type = attrsOf (mkOptionType {
name = "coercibleToString";
description = "value that can be coerced to string";
check = strings.isConvertibleWithToString;
merge = mergeEqualOption;
});
type = types.attrsOf (
types.mkOptionType {
name = "coercibleToString";
description = "value that can be coerced to string";
check = lib.strings.isConvertibleWithToString;
merge = lib.mergeEqualOption;
}
);
default = { };
visible = false;
};
};
config = optionalAttrs (options ? sops.secrets)
(mkIf (config.sops.templates != { }) {
sops.placeholder = mapAttrs
(name: _: mkDefault "<SOPS:${hashString "sha256" name}:PLACEHOLDER>")
config.sops.secrets;
system.activationScripts.renderSecrets = mkIf (cfg.templates != { })
(stringAfter ([ "setupSecrets" ]
++ optional (secretsForUsers != { }) "setupSecretsForUsers") ''
echo Setting up sops templates...
${concatMapStringsSep "\n" (name:
let
tpl = config.sops.templates.${name};
substitute = pkgs.writers.writePython3 "substitute" { }
(readFile ./subs.py);
subst-pairs = pkgs.writeText "pairs" (concatMapStringsSep "\n"
(name:
"${toString config.sops.placeholder.${name}} ${
config.sops.secrets.${name}.path
}") (attrNames config.sops.secrets));
in ''
mkdir -p "${dirOf tpl.path}"
(umask 077; ${substitute} ${tpl.file} ${subst-pairs} > ${tpl.path})
chmod "${tpl.mode}" "${tpl.path}"
chown "${tpl.owner}:${tpl.group}" "${tpl.path}"
'') (attrNames config.sops.templates)}
'');
});
config = lib.optionalAttrs (options ? sops.secrets) (
lib.mkIf (config.sops.templates != { }) {
sops.placeholder = mapAttrs (
name: _: mkDefault "<SOPS:${builtins.hashString "sha256" name}:PLACEHOLDER>"
) config.sops.secrets;
}
);
}

View file

@ -1,26 +0,0 @@
from sys import argv
def substitute(target: str, subst: str) -> str:
with open(target) as f:
content = f.read()
with open(subst) as f:
subst_pairs = f.read().splitlines()
for pair in subst_pairs:
placeholder, path = pair.split()
if placeholder in content:
with open(path) as f:
content = content.replace(placeholder, f.read())
return content
def main() -> None:
target = argv[1]
subst = argv[2]
print(substitute(target, subst))
main()

View file

@ -2,11 +2,12 @@
sopsCall:
if cfg.environment == {} then
if cfg.environment == { } then
sopsCall
else ''
(
${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)}
${sopsCall}
)
''
else
''
(
${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: " export ${n}='${v}'") cfg.environment)}
${sopsCall}
)
''

View file

@ -1,13 +1,25 @@
{ makeSetupHook, gnupg, sops, lib }:
{
makeSetupHook,
gnupg,
sops,
lib,
}:
let
# FIXME: drop after 23.05
propagatedBuildInputs = if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then "deps" else "propagatedBuildInputs";
propagatedBuildInputs =
if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then
"deps"
else
"propagatedBuildInputs";
in
(makeSetupHook {
name = "sops-import-keys-hook";
substitutions = {
gpg = "${gnupg}/bin/gpg";
};
${propagatedBuildInputs} = [ sops gnupg ];
${propagatedBuildInputs} = [
sops
gnupg
];
} ./sops-import-keys-hook.bash)

View file

@ -1,5 +1,5 @@
# shell.nix
with import <nixpkgs> {};
with import <nixpkgs> { };
mkShell {
sopsPGPKeyDirs = [
"./keys"
@ -10,6 +10,6 @@ mkShell {
];
sopsCreateGPGHome = "1";
nativeBuildInputs = [
(pkgs.callPackage ../../.. {}).sops-import-keys-hook
(pkgs.callPackage ../../.. { }).sops-import-keys-hook
];
}

View file

@ -1,4 +1,12 @@
{ stdenv, lib, makeWrapper, gnupg, coreutils, util-linux, unixtools }:
{
stdenv,
lib,
makeWrapper,
gnupg,
coreutils,
util-linux,
unixtools,
}:
stdenv.mkDerivation {
name = "sops-init-gpg-key";
@ -11,9 +19,14 @@ stdenv.mkDerivation {
installPhase = ''
install -m755 -D $src $out/bin/sops-init-gpg-key
wrapProgram $out/bin/sops-init-gpg-key \
--prefix PATH : ${lib.makeBinPath [
coreutils util-linux gnupg unixtools.hostname
]}
--prefix PATH : ${
lib.makeBinPath [
coreutils
util-linux
gnupg
unixtools.hostname
]
}
'';
doInstallCheck = true;

View file

@ -6,7 +6,6 @@ package main
import (
"errors"
"fmt"
"log"
"os"
"os/exec"
"strings"
@ -71,21 +70,16 @@ func MountSecretFs(mountpoint string, keysGID int, _useTmpfs bool, userMode bool
size := mb * 1024 * 1024 / 512 // size in sectors a 512 bytes
cmd := exec.Command("hdiutil", "attach", "-nomount", fmt.Sprintf("ram://%d", int(size)))
out, err := cmd.Output() // /dev/diskN
log.Printf("%q\n", string(out))
diskpath := strings.TrimRight(string(out[:]), " \t\n")
log.Printf("%q\n", diskpath)
log.Printf("hdiutil attach ret %v. out: %s", err, diskpath)
// format as hfs
out, err = exec.Command("newfs_hfs", "-s", diskpath).Output()
log.Printf("newfs_hfs ret %v. out: %s", err, out)
// "posix" mount takes `struct hfs_mount_args` which we dont have bindings for at hand.
// See https://stackoverflow.com/a/49048846/4108673
// err = unix.Mount("hfs", mountpoint, unix.MNT_NOEXEC|unix.MNT_NODEV, mount_args)
// Instead we call:
out, err = exec.Command("mount", "-t", "hfs", "-o", "nobrowse,nodev,nosuid,-m=0751", diskpath, mountpoint).Output()
log.Printf("mount ret %v. out: %s", err, out)
// There is no documented way to check for memfs mountpoint. Thus we place a file.
path := mountpoint + "/sops-nix-secretfs"

View file

@ -1,30 +1,39 @@
{ lib, buildGoModule, stdenv, vendorHash, go, callPackages }:
{
lib,
buildGoModule,
stdenv,
vendorHash,
go,
}:
buildGoModule {
pname = "sops-install-secrets";
version = "0.0.1";
src = lib.sourceByRegex ../.. [ "go\.(mod|sum)" "pkgs" "pkgs/sops-install-secrets.*" ];
src = lib.sourceByRegex ../.. [
"go\.(mod|sum)"
"pkgs"
"pkgs/sops-install-secrets.*"
];
subPackages = [ "pkgs/sops-install-secrets" ];
# requires root privileges for tests
doCheck = false;
passthru.tests = callPackages ./nixos-test.nix { };
outputs = [ "out" ] ++ lib.lists.optionals (stdenv.isLinux) [ "unittest" ];
outputs = [ "out" ] ++
lib.lists.optionals (stdenv.isLinux) [ "unittest" ];
postInstall = ''
go test -c ./pkgs/sops-install-secrets
'' + lib.optionalString (stdenv.isLinux) ''
# *.test is only tested on linux. $unittest does not exist on darwin.
install -D ./sops-install-secrets.test $unittest/bin/sops-install-secrets.test
# newer versions of nixpkgs no longer require this step
if command -v remove-references-to; then
remove-references-to -t ${go} $unittest/bin/sops-install-secrets.test
fi
'';
postInstall =
''
go test -c ./pkgs/sops-install-secrets
''
+ lib.optionalString (stdenv.isLinux) ''
# *.test is only tested on linux. $unittest does not exist on darwin.
install -D ./sops-install-secrets.test $unittest/bin/sops-install-secrets.test
# newer versions of nixpkgs no longer require this step
if command -v remove-references-to; then
remove-references-to -t ${go} $unittest/bin/sops-install-secrets.test
fi
'';
inherit vendorHash;

View file

@ -11,6 +11,7 @@ import (
"os/user"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
@ -29,8 +30,10 @@ type secret struct {
Name string `json:"name"`
Key string `json:"key"`
Path string `json:"path"`
Owner string `json:"owner"`
Group string `json:"group"`
Owner *string `json:"owner,omitempty"`
UID int `json:"uid"`
Group *string `json:"group,omitempty"`
GID int `json:"gid"`
SopsFile string `json:"sopsFile"`
Format FormatType `json:"format"`
Mode string `json:"mode"`
@ -47,18 +50,39 @@ type loggingConfig struct {
SecretChanges bool `json:"secretChanges"`
}
type template struct {
Name string `json:"name"`
Content string `json:"content"`
Path string `json:"path"`
Mode string `json:"mode"`
Owner *string `json:"owner,omitempty"`
UID int `json:"uid"`
Group *string `json:"group,omitempty"`
GID int `json:"gid"`
File string `json:"file"`
RestartUnits []string `json:"restartUnits"`
ReloadUnits []string `json:"reloadUnits"`
value []byte
mode os.FileMode
content string
owner int
group int
}
type manifest struct {
Secrets []secret `json:"secrets"`
SecretsMountPoint string `json:"secretsMountPoint"`
SymlinkPath string `json:"symlinkPath"`
KeepGenerations int `json:"keepGenerations"`
SSHKeyPaths []string `json:"sshKeyPaths"`
GnupgHome string `json:"gnupgHome"`
AgeKeyFile string `json:"ageKeyFile"`
AgeSSHKeyPaths []string `json:"ageSshKeyPaths"`
UseTmpfs bool `json:"useTmpfs"`
UserMode bool `json:"userMode"`
Logging loggingConfig `json:"logging"`
Secrets []secret `json:"secrets"`
Templates []template `json:"templates"`
PlaceholderBySecretName map[string]string `json:"placeholderBySecretName"`
SecretsMountPoint string `json:"secretsMountPoint"`
SymlinkPath string `json:"symlinkPath"`
KeepGenerations int `json:"keepGenerations"`
SSHKeyPaths []string `json:"sshKeyPaths"`
GnupgHome string `json:"gnupgHome"`
AgeKeyFile string `json:"ageKeyFile"`
AgeSSHKeyPaths []string `json:"ageSshKeyPaths"`
UseTmpfs bool `json:"useTmpfs"`
UserMode bool `json:"userMode"`
Logging loggingConfig `json:"logging"`
}
type secretFile struct {
@ -126,12 +150,16 @@ type options struct {
}
type appContext struct {
manifest manifest
secretFiles map[string]secretFile
checkMode CheckMode
ignorePasswd bool
manifest manifest
secretFiles map[string]secretFile
secretByPlaceholder map[string]*secret
checkMode CheckMode
ignorePasswd bool
}
// Keep this in sync with `modules/sops/templates/default.nix`
const RenderedSubdir string = "rendered"
func readManifest(path string) (*manifest, error) {
file, err := os.Open(path)
if err != nil {
@ -146,51 +174,51 @@ func readManifest(path string) (*manifest, error) {
return &m, nil
}
func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, secret *secret) bool {
func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, owner int, group int) bool {
validUG := true
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
validUG = validUG && int(stat.Uid) == secret.owner
validUG = validUG && int(stat.Gid) == secret.group
validUG = validUG && int(stat.Uid) == owner
validUG = validUG && int(stat.Gid) == group
} else {
panic("Failed to cast fileInfo Sys() to *syscall.Stat_t. This is possibly an unsupported OS.")
}
return linkTarget == targetFile && validUG
}
func symlinkSecret(targetFile string, secret *secret, userMode bool) error {
func createSymlink(targetFile string, path string, owner int, group int, userMode bool) error {
for {
stat, err := os.Lstat(secret.Path)
stat, err := os.Lstat(path)
if os.IsNotExist(err) {
if err = os.Symlink(targetFile, secret.Path); err != nil {
return fmt.Errorf("cannot create symlink '%s': %w", secret.Path, err)
if err = os.Symlink(targetFile, path); err != nil {
return fmt.Errorf("cannot create symlink '%s': %w", path, err)
}
if !userMode {
if err = SecureSymlinkChown(secret.Path, targetFile, secret.owner, secret.group); err != nil {
return fmt.Errorf("cannot chown symlink '%s': %w", secret.Path, err)
if err = SecureSymlinkChown(path, targetFile, owner, group); err != nil {
return fmt.Errorf("cannot chown symlink '%s': %w", path, err)
}
}
return nil
} else if err != nil {
return fmt.Errorf("cannot stat '%s': %w", secret.Path, err)
return fmt.Errorf("cannot stat '%s': %w", path, err)
}
if stat.Mode()&os.ModeSymlink == os.ModeSymlink {
linkTarget, err := os.Readlink(secret.Path)
linkTarget, err := os.Readlink(path)
if os.IsNotExist(err) {
continue
} else if err != nil {
return fmt.Errorf("cannot read symlink '%s': %w", secret.Path, err)
} else if linksAreEqual(linkTarget, targetFile, stat, secret) {
return fmt.Errorf("cannot read symlink '%s': %w", path, err)
} else if linksAreEqual(linkTarget, targetFile, stat, owner, group) {
return nil
}
}
if err := os.Remove(secret.Path); err != nil {
return fmt.Errorf("cannot override %s: %w", secret.Path, err)
if err := os.Remove(path); err != nil {
return fmt.Errorf("cannot override %s: %w", path, err)
}
}
}
func symlinkSecrets(targetDir string, secrets []secret, userMode bool) error {
for i, secret := range secrets {
func symlinkSecretsAndTemplates(targetDir string, secrets []secret, templates []template, userMode bool) error {
for _, secret := range secrets {
targetFile := filepath.Join(targetDir, secret.Name)
if targetFile == secret.Path {
continue
@ -199,10 +227,25 @@ func symlinkSecrets(targetDir string, secrets []secret, userMode bool) error {
if err := os.MkdirAll(parent, os.ModePerm); err != nil {
return fmt.Errorf("cannot create parent directory of '%s': %w", secret.Path, err)
}
if err := symlinkSecret(targetFile, &secrets[i], userMode); err != nil {
if err := createSymlink(targetFile, secret.Path, secret.owner, secret.group, userMode); err != nil {
return fmt.Errorf("failed to symlink secret '%s': %w", secret.Path, err)
}
}
for _, template := range templates {
targetFile := filepath.Join(targetDir, RenderedSubdir, template.Name)
if targetFile == template.Path {
continue
}
parent := filepath.Dir(template.Path)
if err := os.MkdirAll(parent, os.ModePerm); err != nil {
return fmt.Errorf("cannot create parent directory of '%s': %w", template.Path, err)
}
if err := createSymlink(targetFile, template.Path, template.owner, template.group, userMode); err != nil {
return fmt.Errorf("failed to symlink template '%s': %w", template.Path, err)
}
}
return nil
}
@ -294,12 +337,20 @@ func decryptSecret(s *secret, sourceFiles map[string]plainData) error {
case Binary, Dotenv, Ini:
sourceFile.binary = plain
case Yaml:
if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil {
return fmt.Errorf("cannot parse yaml of '%s': %w", s.SopsFile, err)
if s.Key == "" {
sourceFile.binary = plain
} else {
if err := yaml.Unmarshal(plain, &sourceFile.data); err != nil {
return fmt.Errorf("Cannot parse yaml of '%s': %w", s.SopsFile, err)
}
}
case JSON:
if err := json.Unmarshal(plain, &sourceFile.data); err != nil {
return fmt.Errorf("cannot parse json of '%s': %w", s.SopsFile, err)
if s.Key == "" {
sourceFile.binary = plain
} else {
if err := json.Unmarshal(plain, &sourceFile.data); err != nil {
return fmt.Errorf("Cannot parse json of '%s': %w", s.SopsFile, err)
}
}
default:
return fmt.Errorf("secret of type %s in %s is not supported", s.Format, s.SopsFile)
@ -309,11 +360,15 @@ func decryptSecret(s *secret, sourceFiles map[string]plainData) error {
case Binary, Dotenv, Ini:
s.value = sourceFile.binary
case Yaml, JSON:
strVal, err := recurseSecretKey(s.Format, sourceFile.data, s.Key)
if err != nil {
return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err)
if s.Key == "" {
s.value = sourceFile.binary
} else {
strVal, err := recurseSecretKey(s.Format, sourceFile.data, s.Key)
if err != nil {
return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err)
}
s.value = []byte(strVal)
}
s.value = []byte(strVal)
}
sourceFiles[s.SopsFile] = sourceFile
return nil
@ -366,24 +421,30 @@ func prepareSecretsDir(secretMountpoint string, linkName string, keysGID int, us
return &dir, nil
}
func createParentDirs(parent string, target string, keysGID int, userMode bool) error {
dirs := strings.Split(filepath.Dir(target), "/")
pathSoFar := parent
for _, dir := range dirs {
pathSoFar = filepath.Join(pathSoFar, dir)
if err := os.MkdirAll(pathSoFar, 0o751); err != nil {
return fmt.Errorf("cannot create directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err)
}
if !userMode {
if err := os.Chown(pathSoFar, 0, int(keysGID)); err != nil {
return fmt.Errorf("cannot own directory '%s' for %s: %w", pathSoFar, filepath.Join(parent, target), err)
}
}
}
return nil
}
func writeSecrets(secretDir string, secrets []secret, keysGID int, userMode bool) error {
for _, secret := range secrets {
fp := filepath.Join(secretDir, secret.Name)
dirs := strings.Split(filepath.Dir(secret.Name), "/")
pathSoFar := secretDir
for _, dir := range dirs {
pathSoFar = filepath.Join(pathSoFar, dir)
if err := os.MkdirAll(pathSoFar, 0o751); err != nil {
return fmt.Errorf("cannot create directory '%s' for %s: %w", pathSoFar, fp, err)
}
if !userMode {
if err := os.Chown(pathSoFar, 0, int(keysGID)); err != nil {
return fmt.Errorf("cannot own directory '%s' for %s: %w", pathSoFar, fp, err)
}
}
if err := createParentDirs(secretDir, secret.Name, keysGID, userMode); err != nil {
return err
}
if err := os.WriteFile(fp, []byte(secret.value), secret.mode); err != nil {
return fmt.Errorf("cannot write %s: %w", fp, err)
}
@ -460,7 +521,7 @@ func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
if err != nil {
return nil, fmt.Errorf("cannot parse ini of '%s': %w", s.SopsFile, err)
}
// TODO: we do not acctually check the contents of the ini here...
// TODO: we do not actually check the contents of the ini here...
}
return &secretFile{
@ -476,7 +537,7 @@ func (app *appContext) validateSopsFile(s *secret, file *secretFile) error {
s.Name, s.SopsFile, s.Format,
file.firstSecret.Format, file.firstSecret.Name)
}
if app.checkMode != Manifest && (s.Format != Binary && s.Format != Dotenv && s.Format != Ini) {
if app.checkMode != Manifest && !(s.Format == Binary || s.Format == Dotenv || s.Format == Ini) && s.Key != "" {
_, err := recurseSecretKey(s.Format, file.keys, s.Key)
if err != nil {
return fmt.Errorf("secret %s in %s is not valid: %w", s.Name, s.SopsFile, err)
@ -485,37 +546,68 @@ func (app *appContext) validateSopsFile(s *secret, file *secretFile) error {
return nil
}
func (app *appContext) validateSecret(secret *secret) error {
mode, err := strconv.ParseUint(secret.Mode, 8, 16)
func validateMode(mode string) (os.FileMode, error) {
parsed, err := strconv.ParseUint(mode, 8, 16)
if err != nil {
return fmt.Errorf("invalid number in mode: %d: %w", mode, err)
return 0, fmt.Errorf("invalid number in mode: %s: %w", mode, err)
}
secret.mode = os.FileMode(mode)
return os.FileMode(parsed), nil
}
func validateOwner(owner string) (int, error) {
lookedUp, err := user.Lookup(owner)
if err != nil {
return 0, fmt.Errorf("failed to lookup user '%s': %w", owner, err)
}
ownerNr, err := strconv.ParseUint(lookedUp.Uid, 10, 64)
if err != nil {
return 0, fmt.Errorf("cannot parse uid %s: %w", lookedUp.Uid, err)
}
return int(ownerNr), nil
}
func validateGroup(group string) (int, error) {
lookedUp, err := user.LookupGroup(group)
if err != nil {
return 0, fmt.Errorf("failed to lookup group '%s': %w", group, err)
}
groupNr, err := strconv.ParseUint(lookedUp.Gid, 10, 64)
if err != nil {
return 0, fmt.Errorf("cannot parse gid %s: %w", lookedUp.Gid, err)
}
return int(groupNr), nil
}
func (app *appContext) validateSecret(secret *secret) error {
mode, err := validateMode(secret.Mode)
if err != nil {
return err
}
secret.mode = mode
if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" {
secret.owner = 0
secret.group = 0
} else if app.checkMode == Off || app.ignorePasswd {
// we only access to the user/group during deployment
owner, err := user.Lookup(secret.Owner)
if err != nil {
return fmt.Errorf("failed to lookup user '%s': %w", secret.Owner, err)
if secret.Owner == nil {
secret.owner = secret.UID
} else {
owner, err := validateOwner(*secret.Owner)
if err != nil {
return err
}
secret.owner = owner
}
ownerNr, err := strconv.ParseUint(owner.Uid, 10, 64)
if err != nil {
return fmt.Errorf("cannot parse uid %s: %w", owner.Uid, err)
}
secret.owner = int(ownerNr)
group, err := user.LookupGroup(secret.Group)
if err != nil {
return fmt.Errorf("failed to lookup group '%s': %w", secret.Group, err)
if secret.Group == nil {
secret.group = secret.GID
} else {
group, err := validateGroup(*secret.Group)
if err != nil {
return err
}
secret.group = group
}
groupNr, err := strconv.ParseUint(group.Gid, 10, 64)
if err != nil {
return fmt.Errorf("cannot parse gid %s: %w", group.Gid, err)
}
secret.group = int(groupNr)
}
if secret.Format == "" {
@ -533,12 +625,79 @@ func (app *appContext) validateSecret(secret *secret) error {
return err
}
app.secretFiles[secret.SopsFile] = *maybeFile
file = *maybeFile
}
return app.validateSopsFile(secret, &file)
}
func renderTemplates(templates []template, secretByPlaceholder map[string]*secret) {
for i := range templates {
template := &templates[i]
rendered := renderTemplate(&template.content, secretByPlaceholder)
template.value = []byte(rendered)
}
}
func renderTemplate(content *string, secretByPlaceholder map[string]*secret) string {
rendered := *content
for placeholder, secret := range secretByPlaceholder {
rendered = strings.ReplaceAll(rendered, placeholder, string(secret.value))
}
return rendered
}
func (app *appContext) validateTemplate(template *template) error {
mode, err := validateMode(template.Mode)
if err != nil {
return err
}
template.mode = mode
if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" {
template.owner = 0
template.group = 0
} else if app.checkMode == Off || app.ignorePasswd {
if template.Owner == nil {
template.owner = template.UID
} else {
owner, err := validateOwner(*template.Owner)
if err != nil {
return err
}
template.owner = owner
}
if template.Group == nil {
template.group = template.GID
} else {
group, err := validateGroup(*template.Group)
if err != nil {
return err
}
template.group = group
}
}
var templateText string
if template.Content != "" {
templateText = template.Content
} else if template.File != "" {
templateBytes, err := os.ReadFile(template.File)
if err != nil {
return fmt.Errorf("cannot read %s: %w", template.File, err)
}
templateText = string(templateBytes)
} else {
return fmt.Errorf("neither content nor file was specified for template %s", template.Name)
}
template.content = templateText
return nil
}
func (app *appContext) validateManifest() error {
m := &app.manifest
if m.GnupgHome != "" {
@ -553,7 +712,22 @@ func (app *appContext) validateManifest() error {
}
for i := range m.Secrets {
if err := app.validateSecret(&m.Secrets[i]); err != nil {
secret := &m.Secrets[i]
if err := app.validateSecret(secret); err != nil {
return err
}
// The Nix module only defines placeholders for secrets if there are
// templates.
if len(m.Templates) > 0 {
placeholder := m.PlaceholderBySecretName[secret.Name]
app.secretByPlaceholder[placeholder] = secret
}
}
for i := range m.Templates {
template := &m.Templates[i]
if err := app.validateTemplate(template); err != nil {
return err
}
}
@ -743,7 +917,7 @@ func symlinkWalk(filename string, linkDirname string, walkFn filepath.WalkFunc)
return filepath.Walk(filename, symWalkFunc)
}
func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, secretDir string, secrets []secret) error {
func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, secretDir string, secrets []secret, templates []template) error {
var restart []string
var reload []string
@ -751,6 +925,10 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s
modifiedSecrets := make(map[string]bool)
removedSecrets := make(map[string]bool)
newTemplates := make(map[string]bool)
modifiedTemplates := make(map[string]bool)
removedTemplates := make(map[string]bool)
// When the symlink path does not exist yet, we are being run in stage-2-init.sh
// where switch-to-configuration is not run so the services would only be restarted
// the next time switch-to-configuration is run.
@ -789,8 +967,46 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s
}
}
// Find modified/new templates
for _, template := range templates {
oldPath := filepath.Join(symlinkPath, RenderedSubdir, template.Name)
newPath := filepath.Join(secretDir, RenderedSubdir, template.Name)
// Read the old file
oldData, err := os.ReadFile(oldPath)
if err != nil {
if os.IsNotExist(err) {
// File did not exist before
restart = append(restart, template.RestartUnits...)
reload = append(reload, template.ReloadUnits...)
newTemplates[template.Name] = true
continue
}
return err
}
// Read the new file
newData, err := os.ReadFile(newPath)
if err != nil {
return err
}
if !bytes.Equal(oldData, newData) {
restart = append(restart, template.RestartUnits...)
reload = append(reload, template.ReloadUnits...)
modifiedTemplates[template.Name] = true
}
}
writeLines := func(list []string, file string) error {
if len(list) != 0 {
if _, err := os.Stat(filepath.Dir(file)); err != nil {
if os.IsNotExist(err) {
return nil
} else {
return err
}
}
f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
return err
@ -822,7 +1038,8 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s
return nil
}
// Find removed secrets
// Find removed secrets/templates.
symlinkRenderedPath := filepath.Join(symlinkPath, RenderedSubdir)
err := symlinkWalk(symlinkPath, symlinkPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
@ -830,42 +1047,67 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s
if info.IsDir() {
return nil
}
path = strings.TrimPrefix(path, symlinkPath+string(os.PathSeparator))
for _, secret := range secrets {
if secret.Name == path {
return nil
}
// If the path we're looking at isn't in `symlinkRenderedPath`, then
// it's a secret.
rel, err := filepath.Rel(symlinkRenderedPath, path)
if err != nil {
return err
}
isSecret := strings.HasPrefix(rel, "..")
if isSecret {
path = strings.TrimPrefix(path, symlinkPath+string(os.PathSeparator))
for _, secret := range secrets {
if secret.Name == path {
return nil
}
}
removedSecrets[path] = true
} else {
path = strings.TrimPrefix(path, symlinkRenderedPath+string(os.PathSeparator))
for _, template := range templates {
if template.Name == path {
return nil
}
}
removedTemplates[path] = true
}
removedSecrets[path] = true
return nil
})
if err != nil {
return err
}
// Output new/modified/removed secrets
outputChanged := func(changed map[string]bool, regularPrefix, dryPrefix string) {
// Output new/modified/removed secrets/templates
outputChanged := func(noun string, changed map[string]bool, regularPrefix, dryPrefix string) {
if len(changed) > 0 {
s := ""
if len(changed) != 1 {
s = "s"
}
if isDry {
fmt.Printf("%s secret%s: ", dryPrefix, s)
fmt.Printf("%s %s%s: ", dryPrefix, noun, s)
} else {
fmt.Printf("%s secret%s: ", regularPrefix, s)
fmt.Printf("%s %s%s: ", regularPrefix, noun, s)
}
comma := ""
for name := range changed {
fmt.Printf("%s%s", comma, name)
comma = ", "
// Sort the output for deterministic behavior.
keys := make([]string, 0, len(changed))
for key := range changed {
keys = append(keys, key)
}
fmt.Println()
sort.Strings(keys)
fmt.Println(strings.Join(keys, ", "))
}
}
outputChanged(newSecrets, "adding", "would add")
outputChanged(modifiedSecrets, "modifying", "would modify")
outputChanged(removedSecrets, "removing", "would remove")
outputChanged("secret", newSecrets, "adding", "would add")
outputChanged("secret", modifiedSecrets, "modifying", "would modify")
outputChanged("secret", removedSecrets, "removing", "would remove")
outputChanged("rendered secret", newTemplates, "adding", "would add")
outputChanged("rendered secret", modifiedTemplates, "modifying", "would modify")
outputChanged("rendered secret", removedTemplates, "removing", "would remove")
return nil
}
@ -937,6 +1179,26 @@ func replaceRuntimeDir(path, rundir string) (ret string) {
return
}
func writeTemplates(targetDir string, templates []template, keysGID int, userMode bool) error {
for _, template := range templates {
fp := filepath.Join(targetDir, template.Name)
if err := createParentDirs(targetDir, template.Name, keysGID, userMode); err != nil {
return err
}
if err := os.WriteFile(fp, []byte(template.value), template.mode); err != nil {
return fmt.Errorf("cannot write %s: %w", fp, err)
}
if !userMode {
if err := os.Chown(fp, template.owner, template.group); err != nil {
return fmt.Errorf("cannot change owner/group of '%s' to %d/%d: %w", fp, template.owner, template.group, err)
}
}
}
return nil
}
func installSecrets(args []string) error {
opts, err := parseFlags(args)
if err != nil {
@ -965,10 +1227,11 @@ func installSecrets(args []string) error {
}
app := appContext{
manifest: *manifest,
checkMode: opts.checkMode,
ignorePasswd: opts.ignorePasswd,
secretFiles: make(map[string]secretFile),
manifest: *manifest,
checkMode: opts.checkMode,
ignorePasswd: opts.ignorePasswd,
secretFiles: make(map[string]secretFile),
secretByPlaceholder: make(map[string]*secret),
}
if err = app.validateManifest(); err != nil {
@ -1042,10 +1305,13 @@ func installSecrets(args []string) error {
}
}
if err = decryptSecrets(manifest.Secrets); err != nil {
if err := decryptSecrets(manifest.Secrets); err != nil {
return err
}
// Now that the secrets are decrypted, we can render the templates.
renderTemplates(manifest.Templates, app.secretByPlaceholder)
secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGID, manifest.UserMode)
if err != nil {
return fmt.Errorf("failed to prepare new secrets directory: %w", err)
@ -1053,8 +1319,13 @@ func installSecrets(args []string) error {
if err := writeSecrets(*secretDir, manifest.Secrets, keysGID, manifest.UserMode); err != nil {
return fmt.Errorf("cannot write secrets: %w", err)
}
if err := writeTemplates(path.Join(*secretDir, RenderedSubdir), manifest.Templates, keysGID, manifest.UserMode); err != nil {
return fmt.Errorf("cannot render templates: %w", err)
}
if !manifest.UserMode {
if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil {
if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets, manifest.Templates); err != nil {
return fmt.Errorf("cannot request units to restart: %w", err)
}
}
@ -1062,7 +1333,7 @@ func installSecrets(args []string) error {
if isDry {
return nil
}
if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets, manifest.UserMode); err != nil {
if err := symlinkSecretsAndTemplates(manifest.SymlinkPath, manifest.Secrets, manifest.Templates, manifest.UserMode); err != nil {
return fmt.Errorf("failed to prepare symlinks to secret store: %w", err)
}
if err := atomicSymlink(*secretDir, manifest.SymlinkPath); err != nil {

View file

@ -98,12 +98,14 @@ func testGPG(t *testing.T) {
}
}()
nobody := "nobody"
nogroup := "nogroup"
// should create a symlink
yamlSecret := secret{
Name: "test",
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
Owner: &nobody,
Group: &nogroup,
SopsFile: path.Join(assets, "secrets.yaml"),
Path: path.Join(testdir.path, "test-target"),
Mode: "0400",
@ -112,12 +114,13 @@ func testGPG(t *testing.T) {
}
var jsonSecret, binarySecret, dotenvSecret, iniSecret secret
root := "root"
// should not create a symlink
jsonSecret = yamlSecret
jsonSecret.Name = "test2"
jsonSecret.Owner = "root"
jsonSecret.Owner = &root
jsonSecret.Format = "json"
jsonSecret.Group = "root"
jsonSecret.Group = &root
jsonSecret.SopsFile = path.Join(assets, "secrets.json")
jsonSecret.Path = path.Join(testdir.secretsPath, "test2")
jsonSecret.Mode = "0700"
@ -130,16 +133,16 @@ func testGPG(t *testing.T) {
dotenvSecret = yamlSecret
dotenvSecret.Name = "test4"
dotenvSecret.Owner = "root"
dotenvSecret.Group = "root"
dotenvSecret.Owner = &root
dotenvSecret.Group = &root
dotenvSecret.Format = "dotenv"
dotenvSecret.SopsFile = path.Join(assets, "secrets.env")
dotenvSecret.Path = path.Join(testdir.secretsPath, "test4")
iniSecret = yamlSecret
iniSecret.Name = "test5"
iniSecret.Owner = "root"
iniSecret.Group = "root"
iniSecret.Owner = &root
iniSecret.Group = &root
iniSecret.Format = "ini"
iniSecret.SopsFile = path.Join(assets, "secrets.ini")
iniSecret.Path = path.Join(testdir.secretsPath, "test5")
@ -214,11 +217,13 @@ func testSSHKey(t *testing.T) {
ok(t, err)
file.Close()
nobody := "nobody"
nogroup := "nogroup"
s := secret{
Name: "test",
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
Owner: &nobody,
Group: &nogroup,
SopsFile: path.Join(assets, "secrets.yaml"),
Path: target,
Mode: "0400",
@ -247,11 +252,13 @@ func TestAge(t *testing.T) {
ok(t, err)
file.Close()
nobody := "nobody"
nogroup := "nogroup"
s := secret{
Name: "test",
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
Owner: &nobody,
Group: &nogroup,
SopsFile: path.Join(assets, "secrets.yaml"),
Path: target,
Mode: "0400",
@ -280,11 +287,13 @@ func TestAgeWithSSH(t *testing.T) {
ok(t, err)
file.Close()
nobody := "nobody"
nogroup := "nogroup"
s := secret{
Name: "test",
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
Owner: &nobody,
Group: &nogroup,
SopsFile: path.Join(assets, "secrets.yaml"),
Path: target,
Mode: "0400",
@ -314,11 +323,13 @@ func TestValidateManifest(t *testing.T) {
testdir := newTestDir(t)
defer testdir.Remove()
nobody := "nobody"
nogroup := "nogroup"
s := secret{
Name: "test",
Key: "test_key",
Owner: "nobody",
Group: "nogroup",
Owner: &nobody,
Group: &nogroup,
SopsFile: path.Join(assets, "secrets.yaml"),
Path: path.Join(testdir.path, "test-target"),
Mode: "0400",

View file

@ -1,424 +0,0 @@
{ lib, testers }:
let
userPasswordTest = name: extraConfig: testers.runNixOSTest {
inherit name;
nodes.machine = { config, lib, ... }: {
imports = [
../../modules/sops
extraConfig
];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = ./test-assets/secrets.yaml;
secrets.test_key.neededForUsers = true;
secrets."nested/test/file".owner = "example-user";
};
users.users.example-user = lib.mkMerge [
(lib.mkIf (! config.systemd.sysusers.enable) {
isNormalUser = true;
hashedPasswordFile = config.sops.secrets.test_key.path;
})
(lib.mkIf config.systemd.sysusers.enable {
isSystemUser = true;
group = "users";
hashedPasswordFile = config.sops.secrets.test_key.path;
})
];
};
testScript = ''
start_all()
machine.wait_for_unit("multi-user.target")
machine.succeed("getent shadow example-user | grep -q :test_value:") # password was set
machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # regular secrets work...
user = machine.succeed("stat -c%U /run/secrets/nested/test/file").strip() # ...and are owned...
assert user == "example-user", f"Expected 'example-user', got '{user}'"
machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password still exists
# BUG in nixos's overlayfs... systemd crashes on switch-to-configuration test
'' + lib.optionalString (!(extraConfig ? system.etc.overlay.enable)) ''
machine.succeed("/run/current-system/bin/switch-to-configuration test")
machine.succeed("cat /run/secrets/nested/test/file | grep -q 'another value'") # the regular secrets still work after a switch
machine.succeed("cat /run/secrets-for-users/test_key | grep -q 'test_value'") # the user password is still present after a switch
'';
};
in {
ssh-keys = testers.runNixOSTest {
name = "sops-ssh-keys";
nodes.server = { ... }: {
imports = [ ../../modules/sops ];
services.openssh.enable = true;
services.openssh.hostKeys = [{
type = "rsa";
bits = 4096;
path = ./test-assets/ssh-key;
}];
sops.defaultSopsFile = ./test-assets/secrets.yaml;
sops.secrets.test_key = { };
};
testScript = ''
start_all()
server.succeed("cat /run/secrets/test_key | grep -q test_value")
'';
};
pruning = testers.runNixOSTest {
name = "sops-pruning";
nodes.machine = { lib, ... }: {
imports = [ ../../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = ./test-assets/secrets.yaml;
secrets.test_key = { };
keepGenerations = lib.mkDefault 0;
};
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
specialisation.pruning.configuration.sops.keepGenerations = 10;
};
testScript = ''
# Force us to generation 100
machine.succeed("mkdir /run/secrets.d/{2..99} /run/secrets.d/non-numeric")
machine.succeed("ln -fsn /run/secrets.d/99 /run/secrets")
machine.succeed("/run/current-system/activate")
machine.succeed("test -d /run/secrets.d/100")
# Ensure nothing is pruned, these are just random numbers
machine.succeed("test -d /run/secrets.d/1")
machine.succeed("test -d /run/secrets.d/90")
machine.succeed("test -d /run/secrets.d/non-numeric")
machine.succeed("/run/current-system/specialisation/pruning/bin/switch-to-configuration test")
print(machine.succeed("ls -la /run/secrets.d/"))
# Ensure stuff was properly pruned.
# We are now at generation 101 so 92 must exist when we keep 10 generations
# and 91 must not.
machine.fail("test -d /run/secrets.d/91")
machine.succeed("test -d /run/secrets.d/92")
machine.succeed("test -d /run/secrets.d/non-numeric")
'';
};
age-keys = testers.runNixOSTest {
name = "sops-age-keys";
nodes.machine = { lib, ... }: {
imports = [ ../../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = ./test-assets/secrets.yaml;
secrets.test_key = { };
};
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
};
testScript = ''
start_all()
machine.succeed("cat /run/secrets/test_key | grep -q test_value")
'';
};
age-ssh-keys = testers.runNixOSTest {
name = "sops-age-ssh-keys";
nodes.machine = {
imports = [ ../../modules/sops ];
services.openssh.enable = true;
services.openssh.hostKeys = [{
type = "ed25519";
path = ./test-assets/ssh-ed25519-key;
}];
sops = {
defaultSopsFile = ./test-assets/secrets.yaml;
secrets.test_key = { };
# Generate a key and append it to make sure it appending doesn't break anything
age = {
keyFile = "/tmp/testkey";
generateKey = true;
};
};
};
testScript = ''
start_all()
machine.succeed("cat /run/secrets/test_key | grep -q test_value")
'';
};
pgp-keys = testers.runNixOSTest {
name = "sops-pgp-keys";
nodes.server = { pkgs, lib, config, ... }: {
imports = [ ../../modules/sops ];
users.users.someuser = {
isSystemUser = true;
group = "nogroup";
};
sops.gnupg.home = "/run/gpghome";
sops.defaultSopsFile = ./test-assets/secrets.yaml;
sops.secrets.test_key.owner = config.users.users.someuser.name;
sops.secrets."nested/test/file".owner = config.users.users.someuser.name;
sops.secrets.existing-file = {
key = "test_key";
path = "/run/existing-file";
};
# must run before sops
system.activationScripts.gnupghome = lib.stringAfter [ "etc" ] ''
cp -r ${./test-assets/gnupghome} /run/gpghome
chmod -R 700 /run/gpghome
touch /run/existing-file
'';
# Useful for debugging
#environment.systemPackages = [ pkgs.gnupg pkgs.sops ];
#environment.variables = {
# GNUPGHOME = "/run/gpghome";
# SOPS_GPG_EXEC="${pkgs.gnupg}/bin/gpg";
# SOPSFILE = "${./test-assets/secrets.yaml}";
#};
};
testScript = ''
def assertEqual(exp: str, act: str) -> None:
if exp != act:
raise Exception(f"'{exp}' != '{act}'")
start_all()
value = server.succeed("cat /run/secrets/test_key")
assertEqual("test_value", value)
server.succeed("runuser -u someuser -- cat /run/secrets/test_key >&2")
value = server.succeed("cat /run/secrets/nested/test/file")
assertEqual(value, "another value")
target = server.succeed("readlink -f /run/existing-file")
assertEqual("/run/secrets.d/1/existing-file", target.strip())
'';
};
templates = testers.runNixOSTest {
name = "sops-templates";
nodes.machine = { config, lib, ... }: {
imports = [ ../../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = ./test-assets/secrets.yaml;
secrets.test_key = { };
};
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
sops.templates.test_template = {
content = ''
This line is not modified.
The next value will be replaced by ${config.sops.placeholder.test_key}
This line is also not modified.
'';
mode = "0400";
owner = "someuser";
group = "somegroup";
};
sops.templates.test_default.content = ''
Test value: ${config.sops.placeholder.test_key}
'';
users.groups.somegroup = {};
users.users.someuser = {
isSystemUser = true;
group = "somegroup";
};
};
testScript = ''
start_all()
machine.succeed("[ $(stat -c%U /run/secrets-rendered/test_template) = 'someuser' ]")
machine.succeed("[ $(stat -c%G /run/secrets-rendered/test_template) = 'somegroup' ]")
machine.succeed("[ $(stat -c%U /run/secrets-rendered/test_default) = 'root' ]")
machine.succeed("[ $(stat -c%G /run/secrets-rendered/test_default) = 'root' ]")
expected = """
This line is not modified.
The next value will be replaced by test_value
This line is also not modified.
"""
rendered = machine.succeed("cat /run/secrets-rendered/test_template")
expected_default = """
Test value: test_value
"""
rendered_default = machine.succeed("cat /run/secrets-rendered/test_default")
if rendered.strip() != expected.strip() or rendered_default.strip() != expected_default.strip():
raise Exception("Template is not rendered correctly")
'';
};
restart-and-reload = testers.runNixOSTest {
name = "sops-restart-and-reload";
nodes.machine = { pkgs, lib, config, ... }: {
imports = [ ../../modules/sops ];
sops = {
age.keyFile = "/run/age-keys.txt";
defaultSopsFile = ./test-assets/secrets.yaml;
secrets.test_key = {
restartUnits = [ "restart-unit.service" "reload-unit.service" ];
reloadUnits = [ "reload-trigger.service" ];
};
};
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
systemd.services."restart-unit" = {
description = "Restart unit";
# not started on boot
serviceConfig = { ExecStart = "/bin/sh -c 'echo ok > /restarted'"; };
};
systemd.services."reload-unit" = {
description = "Reload unit";
wantedBy = [ "multi-user.target" ];
reloadIfChanged = true;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "/bin/sh -c true";
ExecReload = "/bin/sh -c 'echo ok > /reloaded'";
};
};
systemd.services."reload-trigger" = {
description = "Reload trigger unit";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "/bin/sh -c true";
ExecReload = "/bin/sh -c 'echo ok > /reloaded'";
};
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
machine.fail("test -f /restarted")
machine.fail("test -f /reloaded")
# Nothing is to be restarted after boot
machine.fail("ls /run/nixos/*-list")
# Nothing happens when the secret is not changed
machine.succeed("/run/current-system/bin/switch-to-configuration test")
machine.fail("test -f /restarted")
machine.fail("test -f /reloaded")
# Ensure the secret is changed
machine.succeed(": > /run/secrets/test_key")
# The secret is changed, now something should happen
machine.succeed("/run/current-system/bin/switch-to-configuration test")
# Ensure something happened
machine.succeed("test -f /restarted")
machine.succeed("test -f /reloaded")
with subtest("change detection"):
machine.succeed("rm /run/secrets/test_key")
out = machine.succeed("/run/current-system/bin/switch-to-configuration test")
if "adding secret" not in out:
raise Exception("Addition detection does not work")
machine.succeed(": > /run/secrets/test_key")
out = machine.succeed("/run/current-system/bin/switch-to-configuration test")
if "modifying secret" not in out:
raise Exception("Modification detection does not work")
machine.succeed(": > /run/secrets/another_key")
out = machine.succeed("/run/current-system/bin/switch-to-configuration test")
if "removing secret" not in out:
raise Exception("Removal detection does not work")
with subtest("dry activation"):
machine.succeed("rm /run/secrets/test_key")
machine.succeed(": > /run/secrets/another_key")
out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate")
if "would add secret" not in out:
raise Exception("Dry addition detection does not work")
if "would remove secret" not in out:
raise Exception("Dry removal detection does not work")
machine.fail("test -f /run/secrets/test_key")
machine.succeed("test -f /run/secrets/another_key")
machine.succeed("/run/current-system/bin/switch-to-configuration test")
machine.succeed("test -f /run/secrets/test_key")
machine.succeed("rm /restarted /reloaded")
machine.fail("test -f /run/secrets/another_key")
machine.succeed(": > /run/secrets/test_key")
out = machine.succeed("/run/current-system/bin/switch-to-configuration dry-activate")
if "would modify secret" not in out:
raise Exception("Dry modification detection does not work")
machine.succeed("[ $(cat /run/secrets/test_key | wc -c) = 0 ]")
machine.fail("test -f /restarted") # not done in dry mode
machine.fail("test -f /reloaded") # not done in dry mode
'';
};
user-passwords = userPasswordTest "sops-user-passwords" {
# must run before sops sets up keys
boot.initrd.postDeviceCommands = ''
cp -r ${./test-assets/age-keys.txt} /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
};
} // lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.05") {
user-passwords-sysusers = userPasswordTest "sops-user-passwords-sysusers" ({ pkgs, ... }: {
systemd.sysusers.enable = true;
users.mutableUsers = true;
system.etc.overlay.enable = true;
boot.initrd.systemd.enable = true;
boot.kernelPackages = pkgs.linuxPackages_latest;
# must run before sops sets up keys
systemd.services."sops-install-secrets-for-users".preStart = ''
printf '${builtins.readFile ./test-assets/age-keys.txt}' > /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
});
} // lib.optionalAttrs (lib.versionAtLeast (lib.versions.majorMinor lib.version) "24.11") {
user-passwords-userborn = userPasswordTest "sops-user-passwords-userborn" ({ pkgs, ... }: {
services.userborn.enable = true;
users.mutableUsers = false;
system.etc.overlay.enable = true;
boot.initrd.systemd.enable = true;
boot.kernelPackages = pkgs.linuxPackages_latest;
# must run before sops sets up keys
systemd.services."sops-install-secrets-for-users".preStart = ''
printf '${builtins.readFile ./test-assets/age-keys.txt}' > /run/age-keys.txt
chmod -R 700 /run/age-keys.txt
'';
});
}

View file

@ -1,4 +1,11 @@
{ pkgs ? import <nixpkgs> {} }:
{
pkgs ? import <nixpkgs> { },
}:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [ go delve util-linux gnupg ];
nativeBuildInputs = with pkgs; [
go
delve
util-linux
gnupg
];
}

View file

@ -1,13 +1,25 @@
{ makeSetupHook, gnupg, sops, lib }:
{
makeSetupHook,
gnupg,
sops,
lib,
}:
let
# FIXME: drop after 23.05
propagatedBuildInputs = if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then "deps" else "propagatedBuildInputs";
propagatedBuildInputs =
if (lib.versionOlder (lib.versions.majorMinor lib.version) "23.05") then
"deps"
else
"propagatedBuildInputs";
in
(makeSetupHook {
name = "sops-pgp-hook";
substitutions = {
gpg = "${gnupg}/bin/gpg";
};
${propagatedBuildInputs} = [ sops gnupg ];
${propagatedBuildInputs} = [
sops
gnupg
];
} ./sops-pgp-hook.bash)

View file

@ -1,5 +1,5 @@
# shell.nix
with import <nixpkgs> {};
with import <nixpkgs> { };
mkShell {
sopsPGPKeyDirs = [
"./keys"
@ -9,6 +9,6 @@ mkShell {
"./non-existing-key.gpg"
];
nativeBuildInputs = [
(pkgs.callPackage ../../.. {}).sops-pgp-hook
(pkgs.callPackage ../../.. { }).sops-pgp-hook
];
}

View file

@ -1,16 +1,21 @@
{ pkgs ? import <nixpkgs> {}
{
pkgs ? import <nixpkgs> { },
}:
let
sopsPkgs = import ../. { inherit pkgs; };
in pkgs.stdenv.mkDerivation {
in
pkgs.stdenv.mkDerivation {
name = "env";
nativeBuildInputs = with pkgs; [
bashInteractive
gnupg
util-linux
nix
sopsPkgs.sops-pgp-hook-test
] ++ pkgs.lib.optional (pkgs.stdenv.isLinux) sopsPkgs.sops-install-secrets.unittest;
nativeBuildInputs =
with pkgs;
[
bashInteractive
gnupg
util-linux
nix
sopsPkgs.sops-pgp-hook-test
]
++ pkgs.lib.optional (pkgs.stdenv.isLinux) sopsPkgs.sops-install-secrets.unittest;
# allow to prefetch shell dependencies in build phase
dontUnpack = true;
installPhase = ''

View file

@ -1,6 +1,15 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
{
mkShell,
bashInteractive,
go,
delve,
gnupg,
util-linux,
nix,
golangci-lint,
}:
mkShell {
nativeBuildInputs = [
bashInteractive
go
delve