mirror of
https://github.com/Mic92/sops-nix.git
synced 2025-12-26 22:24:59 +08:00
Merge pull request #268 from pogobanane/feat/home-manager-rebased
Synced feat/home-manager with master
This commit is contained in:
commit
8fec29b009
10 changed files with 570 additions and 79 deletions
66
README.md
66
README.md
|
|
@ -19,6 +19,7 @@ environment variables that can be passed to sops.
|
|||
- Compatible with all NixOS deployment frameworks: [NixOps](https://github.com/NixOS/nixops), nixos-rebuild, [krops](https://github.com/krebs/krops/), [morph](https://github.com/DBCDK/morph), [nixus](https://github.com/Infinisil/nixus), etc.
|
||||
- Version-control friendly: Since all files are encrypted they can be directly committed to version control without worry. Diffs of the secrets are readable, and [can be shown in cleartext](https://github.com/mozilla/sops#showing-diffs-in-cleartext-in-git).
|
||||
- CI friendly: Since sops files can be added to the Nix store without leaking secrets, a machine definition can be built as a whole from a repository, without needing to rely on external secrets or services.
|
||||
- Home-manager friendly: Provides a home-manager module
|
||||
- Works well in teams: sops-nix comes with `nix-shell` hooks that allows multiple people to quickly import all GPG keys.
|
||||
The cryptography used in sops is designed to be scalable: Secrets are only encrypted once with a master key
|
||||
instead of encrypted per machine/developer key.
|
||||
|
|
@ -34,7 +35,7 @@ There is a `configuration.nix` example in the [deployment step](#deploy-example)
|
|||
|
||||
## Supported encryption methods
|
||||
|
||||
sops-nix supports two basic ways of encryption, GPG and `age`.
|
||||
sops-nix supports two basic ways of encryption, GPG and `age`.
|
||||
|
||||
GPG is based on [GnuPG](https://gnupg.org/) and encrypts against GPG public keys. Private GPG keys may
|
||||
be used to decrypt the secrets on the target machine. The tool [`ssh-to-pgp`](https://github.com/Mic92/ssh-to-pgp) can
|
||||
|
|
@ -49,7 +50,7 @@ format to `age` keys.
|
|||
<details>
|
||||
<summary><b>1. Install sops-nix</b></summary>
|
||||
|
||||
Choose one of the following methods:
|
||||
Choose one of the following methods. When using it non-globally with home-manager, refer to [Use with home-manager](#use-with-home-manager).
|
||||
|
||||
#### Flakes (current recommendation)
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ If you use experimental nix flakes support:
|
|||
inputs.sops-nix.url = github:Mic92/sops-nix;
|
||||
# optional, not necessary for the module
|
||||
#inputs.sops-nix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
|
||||
outputs = { self, nixpkgs, sops-nix }: {
|
||||
# change `yourhostname` to your actual hostname
|
||||
nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem {
|
||||
|
|
@ -713,7 +714,7 @@ $ head krb5.keytab
|
|||
"mac": "ENC[AES256_GCM,data:ISjUzaw/5mNiwypmUrOk2DAZnlkbnhURHmTTYA3705NmRsSyUh1PyQvCuwglmaHscwl4GrsnIz4rglvwx1zYa+UUwanR0+VeBqntHwzSNiWhh7qMAQwdUXmdCNiOyeGy6jcSDsXUeQmyIWH6yibr7hhzoQFkZEB7Wbvcw6Sossk=,iv:UilxNvfHN6WkEvfY8ZIJCWijSSpLk7fqSCWh6n8+7lk=,tag:HUTgyL01qfVTCNWCTBfqXw==,type:str]",
|
||||
"pgp": [
|
||||
{
|
||||
|
||||
|
||||
```
|
||||
|
||||
It can be decrypted again like this:
|
||||
|
|
@ -733,6 +734,63 @@ This is how it can be included in your `configuration.nix`:
|
|||
}
|
||||
```
|
||||
|
||||
## Use with home manager
|
||||
|
||||
sops-nix also provides a home-manager module.
|
||||
This module provides a subset of features provided by the system-wide sops-nix since features like the creation of the ramfs and changing the owner of the secrets are not available for non-root users.
|
||||
|
||||
Instead of running as an activation script, sops-nix runs as a systemd user service called `sops-nix.service`.
|
||||
And instead of decrypting to `/run/secrets`, the secrets are decrypted to `$XDG_RUNTIME_DIR/secrets`.
|
||||
**Since the secrets are decrypted there, it's highly recommended to use a tmpfs for `$XDG_RUNTIME_DIR` to avoid storing secrets in plain text on persistent storage. Linux distributions using systemd-logind do that out-of-the-box.**
|
||||
|
||||
Depending on whether you use home-manager system-wide or using a home.nix, you have to import it in a different way.
|
||||
This example show the `channel` approach from the example [Install: nix-channel](#nix-channel) for simplicity, but all other methods work as well.
|
||||
|
||||
```nix
|
||||
{
|
||||
# NixOS system-wide home-manager configuration
|
||||
home-manager.sharedModules = [
|
||||
<sops-nix/modules/home-manager/sops.nix>
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
```nix
|
||||
{
|
||||
# Configuration via home.nix
|
||||
imports = [
|
||||
<sops-nix/modules/home-manager/sops.nix>
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
The actual sops configuration is in the `sops` namespace in your home.nix (or in the `home-manager.users.<name>` namespace when using home-manager system-wide):
|
||||
```nix
|
||||
{
|
||||
sops = {
|
||||
age.keyFile = "/home/user/.age-key.txt"; # must have no password!
|
||||
# It's alos possible to use a ssh key, but only when it has no password:
|
||||
#age.sshKeyPaths = [ "/home/user/path-to-ssh-key" ];
|
||||
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";
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The secrets are decrypted in a systemd user service called `sops-nix`, so other services needing secrets must order after it:
|
||||
```nix
|
||||
{
|
||||
systemd.user.services.mbsync.Unit.After = [ "sops-nix.service" ];
|
||||
}
|
||||
```
|
||||
|
||||
## 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`:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
inherit vendorSha256;
|
||||
};
|
||||
in rec {
|
||||
inherit sops-install-secrets;
|
||||
sops-init-gpg-key = pkgs.callPackage ./pkgs/sops-init-gpg-key {};
|
||||
sops-pgp-hook = pkgs.lib.warn ''
|
||||
sops-pgp-hook is deprecated, use sops-import-keys-hook instead.
|
||||
|
|
@ -23,8 +24,6 @@ in rec {
|
|||
};
|
||||
unit-tests = pkgs.callPackage ./pkgs/unit-tests.nix {};
|
||||
} // (pkgs.lib.optionalAttrs pkgs.stdenv.isLinux {
|
||||
inherit sops-install-secrets;
|
||||
|
||||
lint = pkgs.callPackage ./pkgs/lint.nix {
|
||||
inherit sops-install-secrets;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@
|
|||
inherit (prev) ssh-to-pgp;
|
||||
};
|
||||
nixosModules.sops = import ./modules/sops;
|
||||
homeManagerModules.sops = import ./modules/home-manager/sops.nix;
|
||||
nixosModule = self.nixosModules.sops;
|
||||
homeManagerModule = self.homeManagerModules.sops;
|
||||
packages = forAllSystems (system:
|
||||
import ./default.nix {
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
|
|
|
|||
244
modules/home-manager/sops.nix
Normal file
244
modules/home-manager/sops.nix
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
{ 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
|
||||
'';
|
||||
};
|
||||
|
||||
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.
|
||||
'';
|
||||
};
|
||||
|
||||
path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "%r/secrets/${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.
|
||||
'';
|
||||
};
|
||||
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
manifestFor = suffix: secrets: pkgs.writeTextFile {
|
||||
name = "manifest${suffix}.json";
|
||||
text = builtins.toJSON {
|
||||
secrets = builtins.attrValues secrets;
|
||||
secretsMountPoint = "%r/secrets.d";
|
||||
symlinkPath = "%r/secrets";
|
||||
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"
|
||||
'';
|
||||
};
|
||||
|
||||
manifest = manifestFor "" cfg.secrets;
|
||||
|
||||
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 '${cfg.age.keyFile}' ]]; then
|
||||
echo generating machine-specific age key...
|
||||
mkdir -p $(dirname ${cfg.age.keyFile})
|
||||
# age-keygen sets 0600 by default, no need to chmod.
|
||||
${pkgs.age}/bin/age-keygen -o ${cfg.age.keyFile}
|
||||
fi
|
||||
'' + ''
|
||||
${sops-install-secrets}/bin/sops-install-secrets -ignore-passwd '${manifest}'
|
||||
'')));
|
||||
in {
|
||||
options.sops = {
|
||||
secrets = lib.mkOption {
|
||||
type = lib.types.attrsOf secretType;
|
||||
default = {};
|
||||
description = ''
|
||||
Secrets to decrypt.
|
||||
'';
|
||||
};
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
age = {
|
||||
keyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = "/home/someuser/.age-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 = [];
|
||||
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 = "/home/someuser/.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 = [];
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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 configurated for sops";
|
||||
} {
|
||||
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)
|
||||
);
|
||||
|
||||
systemd.user.services.sops-nix = lib.mkIf pkgs.stdenv.hostPlatform.isLinux {
|
||||
Unit = {
|
||||
Description = "sops-nix activation";
|
||||
};
|
||||
Service = {
|
||||
Type = "oneshot";
|
||||
ExecStart = script;
|
||||
};
|
||||
Install.WantedBy = [ "default.target" ];
|
||||
};
|
||||
|
||||
launchd.agents.sops-nix = {
|
||||
enable = true;
|
||||
config = {
|
||||
ProgramArguments = [ script ];
|
||||
KeepAlive = {
|
||||
Crashed = false;
|
||||
SuccessfulExit = false;
|
||||
};
|
||||
ProcessType = "Background";
|
||||
StandardOutPath = "${config.home.homeDirectory}/Library/Logs/SopsNix/stdout";
|
||||
StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/SopsNix/stderr";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -126,6 +126,7 @@ let
|
|||
sshKeyPaths = cfg.gnupg.sshKeyPaths;
|
||||
ageKeyFile = cfg.age.keyFile;
|
||||
ageSshKeyPaths = cfg.age.sshKeyPaths;
|
||||
userMode = false;
|
||||
logging = {
|
||||
keyImport = builtins.elem "keyImport" cfg.log;
|
||||
secretChanges = builtins.elem "secretChanges" cfg.log;
|
||||
|
|
|
|||
110
pkgs/sops-install-secrets/darwin.go
Normal file
110
pkgs/sops-install-secrets/darwin.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func RuntimeDir() (string, error) {
|
||||
// TODO this could be garbage collected on a 3d basis
|
||||
out, err := exec.Command("getconf", "DARWIN_USER_TEMP_DIR").Output()
|
||||
rundir := strings.TrimRight(string(out[:]), " \t\n")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Cannot get DARWIN_USER_TEMP_DIR: %v", err)
|
||||
}
|
||||
return rundir, nil
|
||||
}
|
||||
|
||||
func SecureSymlinkChown(symlinkToCheck string, expectedTarget string, owner, group int) error {
|
||||
// not sure what O_PATH is needed for anyways
|
||||
fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_SYMLINK|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to open %s: %w", symlinkToCheck, err)
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
|
||||
buf := make([]byte, len(expectedTarget)+1) // oversize by one to detect trunc
|
||||
n, err := unix.Readlinkat(fd, "", buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't readlinkat %s", symlinkToCheck)
|
||||
}
|
||||
if n > len(expectedTarget) || string(buf[:n]) != expectedTarget {
|
||||
return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget)
|
||||
}
|
||||
err = unix.Fchownat(fd, "", owner, group, unix.AT_SYMLINK_NOFOLLOW)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Does:
|
||||
// mkdir /tmp/mymount
|
||||
// NUMSECTORS=128000 # a sector is 512 bytes
|
||||
// mydev=`hdiutil attach -nomount ram://$NUMSECTORS`
|
||||
// newfs_hfs $mydev
|
||||
// mount -t hfs $mydev /tmp/mymount
|
||||
func MountSecretFs(mountpoint string, keysGid int, userMode bool) error {
|
||||
if err := os.MkdirAll(mountpoint, 0751); err != nil {
|
||||
return fmt.Errorf("Cannot create directory '%s': %w", mountpoint, err)
|
||||
}
|
||||
if _, err := os.Stat(mountpoint + "/sops-nix-secretfs"); !errors.Is(err, os.ErrNotExist) {
|
||||
return nil // secret fs already exists
|
||||
}
|
||||
|
||||
// MacOS/darwin options for temporary files:
|
||||
// - /tmp or NSTemporaryDirectory is persistent, and regularly wiped from files not touched >3d
|
||||
// https://wiki.lazarus.freepascal.org/Locating_the_macOS_tmp_directory
|
||||
// - there is no ramfs, also `man statfs` doesn't have flags for memfs things
|
||||
// - we can allocate and mount statically allocated memory (ram://), however most
|
||||
// functions for that are not publicly exposed to userspace.
|
||||
mb := 64 // size in MB
|
||||
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", 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", 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.
|
||||
_, err = os.Create(mountpoint + "/sops-nix-secretfs")
|
||||
|
||||
// This would be the way to check on unix.
|
||||
//buf := unix.Statfs_t{}
|
||||
//if err := unix.Statfs(mountpoint, &buf); err != nil {
|
||||
// return fmt.Errorf("Cannot get statfs for directory '%s': %w", mountpoint, err)
|
||||
//}
|
||||
//
|
||||
//if int32(buf.Type) != RAMFS_MAGIC {
|
||||
// if err := unix.Mount("none", mountpoint, "ramfs", unix.MS_NODEV|unix.MS_NOSUID, "mode=0751"); err != nil {
|
||||
// return fmt.Errorf("Cannot mount: %s", err)
|
||||
// }
|
||||
//}
|
||||
|
||||
if err := os.Chown(mountpoint, 0, int(keysGid)); err != nil {
|
||||
return fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", mountpoint, keysGid, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -15,10 +15,13 @@ buildGoModule {
|
|||
inherit pkgs;
|
||||
};
|
||||
|
||||
outputs = [ "out" "unittest" ];
|
||||
outputs = [ "out" ] ++
|
||||
pkgs.lib.lists.optionals (pkgs.stdenv.isLinux) [ "unittest" ];
|
||||
|
||||
postInstall = ''
|
||||
go test -c ./pkgs/sops-install-secrets
|
||||
'' + pkgs.lib.optionalString (pkgs.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
|
||||
|
|
@ -33,6 +36,6 @@ buildGoModule {
|
|||
homepage = "https://github.com/Mic92/sops-nix";
|
||||
license = licenses.mit;
|
||||
maintainers = with maintainers; [ mic92 ];
|
||||
platforms = platforms.linux;
|
||||
platforms = platforms.linux ++ platforms.darwin;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
69
pkgs/sops-install-secrets/linux.go
Normal file
69
pkgs/sops-install-secrets/linux.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func RuntimeDir() (string, error) {
|
||||
rundir, ok := os.LookupEnv("XDG_RUNTIME_DIR")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("$XDG_RUNTIME_DIR is not set!")
|
||||
}
|
||||
return rundir, nil
|
||||
}
|
||||
|
||||
func SecureSymlinkChown(symlinkToCheck, expectedTarget string, owner, group int) error {
|
||||
// fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0)
|
||||
fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to open %s: %w", symlinkToCheck, err)
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
|
||||
buf := make([]byte, len(expectedTarget)+1) // oversize by one to detect trunc
|
||||
n, err := unix.Readlinkat(fd, "", buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't readlinkat %s", symlinkToCheck)
|
||||
}
|
||||
if n > len(expectedTarget) || string(buf[:n]) != expectedTarget {
|
||||
return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget)
|
||||
}
|
||||
err = unix.Fchownat(fd, "", owner, group, unix.AT_EMPTY_PATH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MountSecretFs(mountpoint string, keysGid int, userMode bool) error {
|
||||
if err := os.MkdirAll(mountpoint, 0751); err != nil {
|
||||
return fmt.Errorf("Cannot create directory '%s': %w", mountpoint, err)
|
||||
}
|
||||
|
||||
// We can't create a ramfs as user
|
||||
if userMode {
|
||||
return nil
|
||||
}
|
||||
|
||||
buf := unix.Statfs_t{}
|
||||
if err := unix.Statfs(mountpoint, &buf); err != nil {
|
||||
return fmt.Errorf("Cannot get statfs for directory '%s': %w", mountpoint, err)
|
||||
}
|
||||
if int32(buf.Type) != RAMFS_MAGIC {
|
||||
if err := unix.Mount("none", mountpoint, "ramfs", unix.MS_NODEV|unix.MS_NOSUID, "mode=0751"); err != nil {
|
||||
return fmt.Errorf("Cannot mount: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Chown(mountpoint, 0, int(keysGid)); err != nil {
|
||||
return fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", mountpoint, keysGid, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// +build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -23,7 +21,6 @@ import (
|
|||
|
||||
"github.com/mozilla-services/yaml"
|
||||
"go.mozilla.org/sops/v3/decrypt"
|
||||
"golang.org/x/sys/unix"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
|
|
@ -58,6 +55,7 @@ type manifest struct {
|
|||
GnupgHome string `json:"gnupgHome"`
|
||||
AgeKeyFile string `json:"ageKeyFile"`
|
||||
AgeSshKeyPaths []string `json:"ageSshKeyPaths"`
|
||||
UserMode bool `json:"userMode"`
|
||||
Logging loggingConfig `json:"logging"`
|
||||
}
|
||||
|
||||
|
|
@ -132,28 +130,6 @@ type appContext struct {
|
|||
ignorePasswd bool
|
||||
}
|
||||
|
||||
func secureSymlinkChown(symlinkToCheck, expectedTarget string, owner, group int) error {
|
||||
fd, err := unix.Open(symlinkToCheck, unix.O_CLOEXEC|unix.O_PATH|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to open %s: %w", symlinkToCheck, err)
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
|
||||
buf := make([]byte, len(expectedTarget)+1) // oversize by one to detect trunc
|
||||
n, err := unix.Readlinkat(fd, "", buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't readlinkat %s", symlinkToCheck)
|
||||
}
|
||||
if n > len(expectedTarget) || string(buf[:n]) != expectedTarget {
|
||||
return fmt.Errorf("symlink %s does not point to %s", symlinkToCheck, expectedTarget)
|
||||
}
|
||||
err = unix.Fchownat(fd, "", owner, group, unix.AT_EMPTY_PATH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot change owner of '%s' to %d/%d: %w", symlinkToCheck, owner, group, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readManifest(path string) (*manifest, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
|
|
@ -179,15 +155,17 @@ func linksAreEqual(linkTarget, targetFile string, info os.FileInfo, secret *secr
|
|||
return linkTarget == targetFile && validUG
|
||||
}
|
||||
|
||||
func symlinkSecret(targetFile string, secret *secret) error {
|
||||
func symlinkSecret(targetFile string, secret *secret, userMode bool) error {
|
||||
for {
|
||||
stat, err := os.Lstat(secret.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 := secureSymlinkChown(secret.Path, targetFile, secret.owner, secret.group); err != nil {
|
||||
return fmt.Errorf("Cannot chown symlink '%s': %w", secret.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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} else if err != nil {
|
||||
|
|
@ -209,7 +187,7 @@ func symlinkSecret(targetFile string, secret *secret) error {
|
|||
}
|
||||
}
|
||||
|
||||
func symlinkSecrets(targetDir string, secrets []secret) error {
|
||||
func symlinkSecrets(targetDir string, secrets []secret, userMode bool) error {
|
||||
for _, secret := range secrets {
|
||||
targetFile := filepath.Join(targetDir, secret.Name)
|
||||
if targetFile == secret.Path {
|
||||
|
|
@ -219,7 +197,7 @@ func symlinkSecrets(targetDir string, secrets []secret) 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, &secret); err != nil {
|
||||
if err := symlinkSecret(targetFile, &secret, userMode); err != nil {
|
||||
return fmt.Errorf("Failed to symlink secret '%s': %w", secret.Path, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -328,29 +306,7 @@ func decryptSecrets(secrets []secret) error {
|
|||
|
||||
const RAMFS_MAGIC int32 = -2054924042
|
||||
|
||||
func mountSecretFs(mountpoint string, keysGid int) error {
|
||||
if err := os.MkdirAll(mountpoint, 0751); err != nil {
|
||||
return fmt.Errorf("Cannot create directory '%s': %w", mountpoint, err)
|
||||
}
|
||||
|
||||
buf := unix.Statfs_t{}
|
||||
if err := unix.Statfs(mountpoint, &buf); err != nil {
|
||||
return fmt.Errorf("Cannot get statfs for directory '%s': %w", mountpoint, err)
|
||||
}
|
||||
if int32(buf.Type) != RAMFS_MAGIC {
|
||||
if err := unix.Mount("none", mountpoint, "ramfs", unix.MS_NODEV|unix.MS_NOSUID, "mode=0751"); err != nil {
|
||||
return fmt.Errorf("Cannot mount: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Chown(mountpoint, 0, int(keysGid)); err != nil {
|
||||
return fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", mountpoint, keysGid, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareSecretsDir(secretMountpoint string, linkName string, keysGid int) (*string, error) {
|
||||
func prepareSecretsDir(secretMountpoint string, linkName string, keysGid int, userMode bool) (*string, error) {
|
||||
var generation uint64
|
||||
linkTarget, err := os.Readlink(linkName)
|
||||
if err == nil {
|
||||
|
|
@ -374,13 +330,15 @@ func prepareSecretsDir(secretMountpoint string, linkName string, keysGid int) (*
|
|||
if err := os.Mkdir(dir, os.FileMode(0751)); err != nil {
|
||||
return nil, fmt.Errorf("mkdir(): %w", err)
|
||||
}
|
||||
if err := os.Chown(dir, 0, int(keysGid)); err != nil {
|
||||
return nil, fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", dir, keysGid, err)
|
||||
if !userMode {
|
||||
if err := os.Chown(dir, 0, int(keysGid)); err != nil {
|
||||
return nil, fmt.Errorf("Cannot change owner/group of '%s' to 0/%d: %w", dir, keysGid, err)
|
||||
}
|
||||
}
|
||||
return &dir, nil
|
||||
}
|
||||
|
||||
func writeSecrets(secretDir string, secrets []secret, keysGid int) error {
|
||||
func writeSecrets(secretDir string, secrets []secret, keysGid int, userMode bool) error {
|
||||
for _, secret := range secrets {
|
||||
fp := filepath.Join(secretDir, secret.Name)
|
||||
|
||||
|
|
@ -391,23 +349,27 @@ func writeSecrets(secretDir string, secrets []secret, keysGid int) error {
|
|||
if err := os.MkdirAll(pathSoFar, 0751); err != nil {
|
||||
return fmt.Errorf("Cannot create directory '%s' for %s: %w", pathSoFar, fp, err)
|
||||
}
|
||||
if err := os.Chown(pathSoFar, 0, int(keysGid)); err != nil {
|
||||
return fmt.Errorf("Cannot own 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 := ioutil.WriteFile(fp, []byte(secret.value), secret.mode); err != nil {
|
||||
return fmt.Errorf("Cannot write %s: %w", fp, err)
|
||||
}
|
||||
if err := os.Chown(fp, secret.owner, secret.group); err != nil {
|
||||
return fmt.Errorf("Cannot change owner/group of '%s' to %d/%d: %w", fp, secret.owner, secret.group, err)
|
||||
if !userMode {
|
||||
if err := os.Chown(fp, secret.owner, secret.group); err != nil {
|
||||
return fmt.Errorf("Cannot change owner/group of '%s' to %d/%d: %w", fp, secret.owner, secret.group, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupKeysGroup() (int, error) {
|
||||
group, err := user.LookupGroup("keys")
|
||||
func lookupGroup(groupname string) (int, error) {
|
||||
group, err := user.LookupGroup(groupname)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Failed to lookup 'keys' group: %w", err)
|
||||
}
|
||||
|
|
@ -418,6 +380,18 @@ func lookupKeysGroup() (int, error) {
|
|||
return int(gid), nil
|
||||
}
|
||||
|
||||
func lookupKeysGroup() (int, error) {
|
||||
gid, err1 := lookupGroup("keys")
|
||||
if err1 == nil {
|
||||
return gid, nil
|
||||
}
|
||||
gid, err2 := lookupGroup("nogroup")
|
||||
if err2 == nil {
|
||||
return gid, nil
|
||||
}
|
||||
return 0, fmt.Errorf("Can't find group 'keys' nor 'nogroup' (%w).", err2)
|
||||
}
|
||||
|
||||
func (app *appContext) loadSopsFile(s *secret) (*secretFile, error) {
|
||||
if app.checkMode == Manifest {
|
||||
return &secretFile{firstSecret: s}, nil
|
||||
|
|
@ -488,7 +462,7 @@ func (app *appContext) validateSecret(secret *secret) error {
|
|||
if app.ignorePasswd || os.Getenv("NIXOS_ACTION") == "dry-activate" {
|
||||
secret.owner = 0
|
||||
secret.group = 0
|
||||
} else if app.checkMode == Off {
|
||||
} 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 {
|
||||
|
|
@ -873,7 +847,7 @@ func parseFlags(args []string) (*options, error) {
|
|||
}
|
||||
var checkMode string
|
||||
fs.StringVar(&checkMode, "check-mode", "off", `Validate configuration without installing it (possible values: "manifest","sopsfile","off")`)
|
||||
fs.BoolVar(&opts.ignorePasswd, "ignore-passwd", false, `Don't look up anything in /etc/passwd. Causes everything to be owned by root:root`)
|
||||
fs.BoolVar(&opts.ignorePasswd, "ignore-passwd", false, `Don't look up anything in /etc/passwd. Causes everything to be owned by root:root or the user executing the tool in user mode`)
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -893,6 +867,19 @@ func parseFlags(args []string) (*options, error) {
|
|||
return &opts, nil
|
||||
}
|
||||
|
||||
func replaceRuntimeDir(path, rundir string) (ret string) {
|
||||
parts := strings.Split(path, "%%")
|
||||
first := true
|
||||
for _, part := range parts {
|
||||
if !first {
|
||||
ret += "%"
|
||||
}
|
||||
first = false
|
||||
ret += strings.ReplaceAll(part, "%r", rundir)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func installSecrets(args []string) error {
|
||||
opts, err := parseFlags(args)
|
||||
if err != nil {
|
||||
|
|
@ -904,6 +891,21 @@ func installSecrets(args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if manifest.UserMode {
|
||||
rundir, err := RuntimeDir()
|
||||
if opts.checkMode == Off && err != nil {
|
||||
return fmt.Errorf("Error: %v", err)
|
||||
}
|
||||
manifest.SecretsMountPoint = replaceRuntimeDir(manifest.SecretsMountPoint, rundir)
|
||||
manifest.SymlinkPath = replaceRuntimeDir(manifest.SymlinkPath, rundir)
|
||||
var newSecrets []secret
|
||||
for _, secret := range manifest.Secrets {
|
||||
secret.Path = replaceRuntimeDir(secret.Path, rundir)
|
||||
newSecrets = append(newSecrets, secret)
|
||||
}
|
||||
manifest.Secrets = newSecrets
|
||||
}
|
||||
|
||||
app := appContext{
|
||||
manifest: *manifest,
|
||||
checkMode: opts.checkMode,
|
||||
|
|
@ -931,7 +933,7 @@ func installSecrets(args []string) error {
|
|||
|
||||
isDry := os.Getenv("NIXOS_ACTION") == "dry-activate"
|
||||
|
||||
if err := mountSecretFs(manifest.SecretsMountPoint, keysGid); err != nil {
|
||||
if err := MountSecretFs(manifest.SecretsMountPoint, keysGid, manifest.UserMode); err != nil {
|
||||
return fmt.Errorf("Failed to mount filesystem for secrets: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -983,21 +985,23 @@ func installSecrets(args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGid)
|
||||
secretDir, err := prepareSecretsDir(manifest.SecretsMountPoint, manifest.SymlinkPath, keysGid, manifest.UserMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to prepare new secrets directory: %w", err)
|
||||
}
|
||||
if err := writeSecrets(*secretDir, manifest.Secrets, keysGid); err != nil {
|
||||
if err := writeSecrets(*secretDir, manifest.Secrets, keysGid, manifest.UserMode); err != nil {
|
||||
return fmt.Errorf("Cannot write secrets: %w", err)
|
||||
}
|
||||
if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil {
|
||||
return fmt.Errorf("Cannot request units to restart: %w", err)
|
||||
if !manifest.UserMode {
|
||||
if err := handleModifications(isDry, manifest.Logging, manifest.SymlinkPath, *secretDir, manifest.Secrets); err != nil {
|
||||
return fmt.Errorf("Cannot request units to restart: %w", err)
|
||||
}
|
||||
}
|
||||
// No need to perform the actual symlinking
|
||||
if isDry {
|
||||
return nil
|
||||
}
|
||||
if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets); err != nil {
|
||||
if err := symlinkSecrets(manifest.SymlinkPath, manifest.Secrets, manifest.UserMode); err != nil {
|
||||
return fmt.Errorf("Failed to prepare symlinks to secret store: %w", err)
|
||||
}
|
||||
if err := atomicSymlink(*secretDir, manifest.SymlinkPath); err != nil {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// +build linux
|
||||
//go:build linux || darwin
|
||||
// +build linux darwin
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue