├── .github └── settings.yml ├── .gitignore ├── .mergify.yml ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── bors.toml ├── cmd └── systemd-vaultd-update-secrets │ └── main.go ├── default.nix ├── epoll.go ├── etc ├── systemd-vaultd.service └── systemd-vaultd.socket ├── flake.lock ├── flake.nix ├── go.mod ├── justfile ├── main.go ├── nix ├── checks │ ├── dev-vault-server.nix │ ├── flake-module.nix │ ├── nixos-test.nix │ ├── systemd-vaultd-test.nix │ ├── unittests.nix │ └── vault-agent-test.nix └── modules │ ├── systemd-vaultd.nix │ ├── vault-agent.nix │ └── vault-secrets.nix ├── pyproject.toml ├── renovate.json ├── secrets.go ├── setup.cfg ├── systemd_sockets.go ├── tests ├── agent-config.hcl ├── command.py ├── conftest.py ├── random_service.py ├── root.py ├── setup-vault ├── systemd_vaultd.py ├── tempdir.py ├── test_blocking_secret.py ├── test_socket_activation.py ├── test_vault.py └── vault-agent-example.hcl └── watcher.go /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | # See https://developer.github.com/v3/repos/#edit for all available settings. 3 | 4 | # The name of the repository. Changing this will rename the repository 5 | name: systemd-vaultd 6 | 7 | # A short description of the repository that will show up on GitHub 8 | description: Provide access to vault secrets to systemd services 9 | 10 | # A URL with more information about the repository 11 | homepage: "" 12 | 13 | # A comma-separated list of topics to set on the repository 14 | topics: "" 15 | 16 | # Either `true` to make the repository private, or `false` to make it public. 17 | private: false 18 | 19 | # Either `true` to enable issues for this repository, `false` to disable them. 20 | has_issues: true 21 | 22 | # Either `true` to enable projects for this repository, or `false` to disable them. 23 | # If projects are disabled for the organization, passing `true` will cause an API error. 24 | has_projects: false 25 | 26 | # Either `true` to enable the wiki for this repository, `false` to disable it. 27 | has_wiki: true 28 | 29 | # Either `true` to enable downloads for this repository, `false` to disable them. 30 | has_downloads: false 31 | 32 | # Updates the default branch for this repository. 33 | default_branch: master 34 | 35 | # Either `true` to allow squash-merging pull requests, or `false` to prevent 36 | # squash-merging. 37 | allow_squash_merge: true 38 | 39 | # Either `true` to allow merging pull requests with a merge commit, or `false` 40 | # to prevent merging pull requests with merge commits. 41 | allow_merge_commit: true 42 | 43 | # Either `true` to allow rebase-merging pull requests, or `false` to prevent 44 | # rebase-merging. 45 | allow_rebase_merge: true 46 | 47 | # Either `true` to enable automatic deletion of branches on merge, or `false` to disable 48 | delete_branch_on_merge: true 49 | 50 | # Either `true` to enable automated security fixes, or `false` to disable 51 | # automated security fixes. 52 | enable_automated_security_fixes: true 53 | 54 | # Either `true` to enable vulnerability alerts, or `false` to disable 55 | # vulnerability alerts. 56 | enable_vulnerability_alerts: true 57 | 58 | # Labels: define labels for Issues and Pull Requests 59 | # 60 | labels: 61 | # NOTE: leave that up to the https://github.com/numtide/.github repo 62 | # - name: bug 63 | # color: CC0000 64 | # description: An issue with the system 🐛. 65 | 66 | # - name: feature 67 | # # If including a `#`, make sure to wrap it with quotes! 68 | # color: '#336699' 69 | # description: New functionality. 70 | 71 | # - name: Help Wanted 72 | # # Provide a new name to rename an existing label 73 | # new_name: first-timers-only 74 | 75 | # Milestones: define milestones for Issues and Pull Requests 76 | milestones: 77 | # - title: milestone-title 78 | # description: milestone-description 79 | # # The state of the milestone. Either `open` or `closed` 80 | # state: open 81 | 82 | # Collaborators: give specific users access to this repository. 83 | # See https://docs.github.com/en/rest/reference/repos#add-a-repository-collaborator for available options 84 | collaborators: 85 | # - username: numtide-bot 86 | # Note: `permission` is only valid on organization-owned repositories. 87 | # The permission to grant the collaborator. Can be one of: 88 | # * `pull` - can pull, but not push to or administer this repository. 89 | # * `push` - can pull and push, but not administer this repository. 90 | # * `admin` - can pull, push and administer this repository. 91 | # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. 92 | # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. 93 | # permission: push 94 | 95 | # See https://docs.github.com/en/rest/reference/teams#add-or-update-team-repository-permissions for available options 96 | teams: 97 | - name: network 98 | # The permission to grant the team. Can be one of: 99 | # * `pull` - can pull, but not push to or administer this repository. 100 | # * `push` - can pull and push, but not administer this repository. 101 | # * `admin` - can pull, push and administer this repository. 102 | # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. 103 | # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. 104 | permission: maintain 105 | 106 | branches: 107 | - name: master 108 | # https://docs.github.com/en/rest/reference/repos#update-branch-protection 109 | # Branch Protection settings. Set to null to disable 110 | protection: 111 | # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. 112 | required_pull_request_reviews: 113 | # # The number of approvals required. (1-6) 114 | # required_approving_review_count: 1 115 | # # Dismiss approved reviews automatically when a new commit is pushed. 116 | # dismiss_stale_reviews: true 117 | # # Blocks merge until code owners have reviewed. 118 | # require_code_owner_reviews: true 119 | # # Specify which users and teams can dismiss pull request reviews. Pass an empty dismissal_restrictions object to disable. User and team dismissal_restrictions are only available for organization-owned repositories. Omit this parameter for personal repositories. 120 | # dismissal_restrictions: 121 | # users: [] 122 | # teams: [] 123 | # Required. Require status checks to pass before merging. Set to null to disable 124 | required_status_checks: 125 | # Required. Require branches to be up to date before merging. 126 | strict: true 127 | # Required. The list of status checks to require in order to merge into this branch 128 | contexts: [ "bors" ] 129 | # Required. Enforce all configured restrictions for administrators. Set to true to enforce required status checks for repository administrators. Set to null to disable. 130 | enforce_admins: false 131 | # Disabled for bors to work 132 | required_linear_history: false 133 | # Required. Restrict who can push to this branch. Team and user restrictions are only available for organization-owned repositories. Set to null to disable. 134 | restrictions: 135 | apps: [ "bors" "github-actions[bot]" ] 136 | users: [] 137 | teams: [] 138 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | *.log 4 | 5 | # binary 6 | systemd-vaultd 7 | tmp/ 8 | 9 | # nix-build symlinks 10 | result* 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | queue_rules: 2 | - name: default 3 | merge_conditions: 4 | - check-success=buildbot/nix-eval 5 | defaults: 6 | actions: 7 | queue: 8 | merge_method: rebase 9 | pull_request_rules: 10 | - name: merge using the merge queue 11 | conditions: 12 | - base=main 13 | - label~=merge-queue|dependencies 14 | actions: 15 | queue: {} 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jörg Thalheim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DESTDIR ?= 2 | GO ?= go 3 | INSTALL ?= install 4 | SED ?= sed 5 | RM ?= rm 6 | MKDIR_P ?= mkdir -p 7 | PREFIX ?= $(DESTDIR)/usr 8 | SERVICE_DIR ?= $(PREFIX)/lib/systemd/system 9 | 10 | all: systemd-vaultd 11 | 12 | systemd-vaultd: 13 | $(GO) build . 14 | 15 | $(SERVICE_DIR): 16 | $(MKDIR_P) "$(SERVICE_DIR)" 17 | 18 | install: systemd-vaultd $(SERVICE_DIR) 19 | $(INSTALL) -m755 -D systemd-vaultd "$(PREFIX)/bin/systemd-vaultd" 20 | $(SED) -e "s!/usr/bin/systemd/vaultd!$(PREFIX)/bin/systemd-vaultd!" etc/systemd-vaultd.service > "$(SERVICE_DIR)/systemd-vaultd.service" 21 | $(INSTALL) -m644 -D etc/systemd-vaultd.socket "$(SERVICE_DIR)/systemd-vaultd.socket" 22 | 23 | clean: 24 | $(RM) -rf systemd-vaultd 25 | 26 | .PHONY: all clean 27 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env hivemind 2 | systemd-service: sleep 3 && systemd-run --user --collect -u vault-nixos3.service -p LoadCredential=foo:$(pwd)/tmp/sock --wait --pipe cat '${CREDENTIALS_DIRECTORY}/foo' 3 | vault: vault server -dev -dev-root-token-id secret 4 | vault-agent: sleep 5 && ./tests/setup-vault && vault agent -config ./tests/vault-agent-example.hcl 5 | systemd-vaultd: go run . -secrets tmp/secrets -sock tmp/sock 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # systemd-vaultd - load vault credentials with systemd units 2 | 3 | > Mostly written in a train 4 | 5 | - Jörg Thalheim 6 | 7 | systemd-vaultd is a proxy between systemd and [vault agent](https://vaultproject.io). 8 | It provides a unix socket that can be used in systemd services in the 9 | `LoadCredential` option and then waits for vault agent to write these secrets in 10 | json format at `/run/systemd-vaultd/.service.json`. 11 | 12 | This project's goal is to simplify the loading of [HashiCorp 13 | Vault](https://www.vaultproject.io/) secrets from 14 | [systemd](https://systemd.io/) units. 15 | 16 | ## Problem statement 17 | 18 | Systemd has an option called `LoadCredentials` that allows to provide 19 | credentials to a service: 20 | 21 | ```conf 22 | # myservice.service 23 | [Service] 24 | ExecStart=/usr/bin/myservice.sh 25 | LoadCredential=foobar:/etc/myfoobarcredential.txt 26 | ``` 27 | 28 | In this case systemd will load credential the file 29 | `/etc/myfoobarcredential.txt` and provide it to the service at 30 | `$CREDENTIAL_PATH/foobar`. 31 | 32 | It's handy because it bypasses file permission issues. 33 | /etc/myfoobarcredential.txt can be owned by root, and the unit run as a 34 | different or dynamic user. 35 | 36 | While vault agent also supports writing these secrets, a major issue is that 37 | the consumer service may be started before vault agent was able to retrieve 38 | secrets from vault. In that case, systemd would fail to start the service. 39 | 40 | ## The solution 41 | 42 | In order to do so, I wrote a `systemd-vaultd` service which acts as a proxy 43 | between systemd and vault agent that is running on the machine. It provides a 44 | unix socket that can be used in systemd services in the `LoadCredential` 45 | option and then waits for vault agent to write these secrets at 46 | `/run/systemd-vaultd/.json`. 47 | 48 | We take advantage that in addition to normal paths, systemd also supports 49 | loading credentials from unix sockets. 50 | 51 | With `systemd-vaultd` the service `myservice.service` would look like this: 52 | 53 | ```conf 54 | [Service] 55 | ExecStart=/usr/bin/myservice.sh 56 | LoadCredential=foobar:/run/systemd-vaultd/sock 57 | ``` 58 | 59 | vault agent is then expected to write secrets to `/run/systemd-vaultd/` in json format. 60 | 61 | ``` 62 | template { 63 | # this exposes all secrets in `secret/my-secret` to the service 64 | contents = "#{{ with secret \"secret/my-secret\" }}{{ .Data.data | toJSON }}{{ end }}" 65 | 66 | # an alternative is to expose only selected secrets like this: 67 | # contents = <") 109 | os.Exit(1) 110 | } 111 | serviceName, err := getSystemdServiceName() 112 | if err != nil { 113 | fmt.Println(err) 114 | os.Exit(1) 115 | } 116 | 117 | target := os.Args[1] 118 | if err := updateSecrets(serviceName, target); err != nil { 119 | fmt.Println(err) 120 | os.Exit(1) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | pkgs.buildGoModule { 3 | name = "systemd-vaultd"; 4 | src = ./.; 5 | vendorHash = null; 6 | meta = with pkgs.lib; { 7 | description = "A proxy for secrets between systemd services and vault"; 8 | homepage = "https://github.com/numtide/systemd-vaultd"; 9 | license = licenses.mit; 10 | maintainers = with maintainers; [ mic92 ]; 11 | platforms = platforms.unix; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /epoll.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "syscall" 7 | ) 8 | 9 | const ( 10 | EPOLLET = 1 << 31 11 | ) 12 | 13 | func (s *server) epollWatch(fd int) error { 14 | event := syscall.EpollEvent{ 15 | Fd: int32(fd), 16 | Events: syscall.EPOLLHUP | EPOLLET, 17 | } 18 | return syscall.EpollCtl(s.epfd, syscall.EPOLL_CTL_ADD, fd, &event) 19 | } 20 | 21 | func (s *server) epollDelete(fd int) error { 22 | return syscall.EpollCtl(s.epfd, syscall.EPOLL_CTL_DEL, fd, &syscall.EpollEvent{}) 23 | } 24 | 25 | func (s *server) handleEpoll() { 26 | events := make([]syscall.EpollEvent, 1024) 27 | for { 28 | n, errno := syscall.EpollWait(s.epfd, events, -1) 29 | if n == -1 { 30 | if errno == syscall.EINTR { 31 | continue 32 | } 33 | log.Fatalf("connection cleaner: epoll wait failed with %v", errno) 34 | } 35 | ready := events[:n] 36 | for _, event := range ready { 37 | if event.Events&(syscall.EPOLLHUP|syscall.EPOLLERR) != 0 { 38 | if err := s.epollDelete(int(event.Fd)); err != nil && !errors.Is(err, syscall.ENOENT) { 39 | log.Printf("failed to remove socket from epoll: %s", err) 40 | } 41 | s.connectionClosed <- int(event.Fd) 42 | } else { 43 | log.Printf("Unhandled epoll event: %d for fd %d", event.Events, event.Fd) 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /etc/systemd-vaultd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=systemd-vaultd daemon 3 | Requires=systemd-vaultd.socket 4 | After=systemd-vaultd.socket 5 | 6 | [Service] 7 | ExecStart=/usr/bin/systemd-vaultd 8 | Restart=yes 9 | ProtectSystem=strict 10 | ProtectHome=yes 11 | PrivateDevices=yes 12 | PrivateNetwork=yes 13 | PrivateUsers=yes 14 | ProtectKernelTunables=yes 15 | ProtectKernelModules=yes 16 | ProtectControlGroups=yes 17 | RestrictAddressFamilies=AF_UNIX 18 | MemoryDenyWriteExecute=yes 19 | SystemCallFilter=@default @file-system @basic-io @system-service @signal @io-event @network-io 20 | 21 | [Install] 22 | Also=systemd-vaultd.socket 23 | -------------------------------------------------------------------------------- /etc/systemd-vaultd.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=systemd-vaultd socket 3 | 4 | [Socket] 5 | ListenStream=/run/systemd-vaultd/sock 6 | 7 | [Install] 8 | WantedBy=sockets.target 9 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": [ 6 | "nixpkgs" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1698579227, 11 | "narHash": "sha256-KVWjFZky+gRuWennKsbo6cWyo7c/z/VgCte5pR9pEKg=", 12 | "owner": "hercules-ci", 13 | "repo": "flake-parts", 14 | "rev": "f76e870d64779109e41370848074ac4eaa1606ec", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "hercules-ci", 19 | "repo": "flake-parts", 20 | "type": "github" 21 | } 22 | }, 23 | "nixpkgs": { 24 | "locked": { 25 | "lastModified": 1698443389, 26 | "narHash": "sha256-/IhqtAuFPL1gew2h1+b+xQipv2WVt9EuszSHz5a4PNI=", 27 | "owner": "NixOS", 28 | "repo": "nixpkgs", 29 | "rev": "a9d001fd4af2df7f5702bbdb28a0081c855cb625", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "NixOS", 34 | "ref": "nixos-unstable-small", 35 | "repo": "nixpkgs", 36 | "type": "github" 37 | } 38 | }, 39 | "root": { 40 | "inputs": { 41 | "flake-parts": "flake-parts", 42 | "nixpkgs": "nixpkgs", 43 | "treefmt-nix": "treefmt-nix" 44 | } 45 | }, 46 | "treefmt-nix": { 47 | "inputs": { 48 | "nixpkgs": [ 49 | "nixpkgs" 50 | ] 51 | }, 52 | "locked": { 53 | "lastModified": 1698438538, 54 | "narHash": "sha256-AWxaKTDL3MtxaVTVU5lYBvSnlspOS0Fjt8GxBgnU0Do=", 55 | "owner": "numtide", 56 | "repo": "treefmt-nix", 57 | "rev": "5deb8dc125a9f83b65ca86cf0c8167c46593e0b1", 58 | "type": "github" 59 | }, 60 | "original": { 61 | "owner": "numtide", 62 | "repo": "treefmt-nix", 63 | "type": "github" 64 | } 65 | } 66 | }, 67 | "root": "root", 68 | "version": 7 69 | } 70 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Description for the project"; 3 | 4 | inputs = { 5 | flake-parts.url = "github:hercules-ci/flake-parts"; 6 | flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 7 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small"; 8 | 9 | treefmt-nix.url = "github:numtide/treefmt-nix"; 10 | treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; 11 | }; 12 | 13 | outputs = inputs @ { flake-parts, ... }: 14 | flake-parts.lib.mkFlake { inherit inputs; } { 15 | systems = [ "x86_64-linux" "aarch64-linux" ]; 16 | imports = [ 17 | ./nix/checks/flake-module.nix 18 | ]; 19 | perSystem = 20 | { config 21 | , pkgs 22 | , ... 23 | }: { 24 | packages.default = pkgs.callPackage ./default.nix { }; 25 | devShells.default = pkgs.mkShellNoCC { 26 | buildInputs = with pkgs; [ 27 | python3.pkgs.pytest 28 | python3.pkgs.mypy 29 | 30 | golangci-lint 31 | vault 32 | systemd 33 | hivemind 34 | go 35 | just 36 | config.treefmt.build.wrapper 37 | ]; 38 | }; 39 | 40 | }; 41 | flake.nixosModules = { 42 | vaultAgent = ./nix/modules/vault-agent.nix; 43 | systemdVaultd = ./nix/modules/systemd-vaultd.nix; 44 | }; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/numtide/systemd-vaultd 2 | 3 | go 1.17 4 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: 2 | @just --list 3 | 4 | # Format and lint project 5 | fmt: 6 | treefmt 7 | 8 | # Build the project 9 | build: 10 | go build . 11 | 12 | # Run linters not covered by treefmt 13 | lint: 14 | golangci-lint run 15 | mypy ./tests 16 | 17 | # Run unitests 18 | test: 19 | pytest -s ./tests 20 | 21 | # Local vault + systemd-vaultd 22 | up: 23 | hivemind 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "syscall" 14 | ) 15 | 16 | type server struct { 17 | Socket string 18 | SecretDir string 19 | epfd int 20 | inotifyRequests chan inotifyRequest 21 | connectionClosed chan int 22 | } 23 | 24 | func inheritSocket() *net.UnixListener { 25 | socks := systemdSockets(true) 26 | stat := &syscall.Stat_t{} 27 | for _, s := range socks { 28 | fd := s.Fd() 29 | err := syscall.Fstat(int(fd), stat) 30 | if err != nil { 31 | log.Printf("Received invalid file descriptor from systemd for fd%d: %v", fd, err) 32 | continue 33 | } 34 | listener, err := net.FileListener(s) 35 | if err != nil { 36 | log.Printf("Received file descriptor %d from systemd that is not a valid socket: %v", fd, err) 37 | continue 38 | } 39 | unixListener, ok := listener.(*net.UnixListener) 40 | if !ok { 41 | log.Printf("Ignore file descriptor %d from systemd, which is not a unix socket", fd) 42 | continue 43 | } 44 | log.Printf("Use unix socket received from systemd") 45 | return unixListener 46 | } 47 | return nil 48 | } 49 | 50 | func listenSocket(path string) (*net.UnixListener, error) { 51 | s := inheritSocket() 52 | if s != nil { 53 | return s, nil 54 | } 55 | if err := syscall.Unlink(path); err != nil && !os.IsNotExist(err) { 56 | return nil, fmt.Errorf("Cannot remove old socket: %v", err) 57 | } 58 | abs, err := filepath.Abs(path) 59 | if err != nil { 60 | return nil, fmt.Errorf("'%s' is not a valid socket path: %v", path, err) 61 | } 62 | addr, err := net.ResolveUnixAddr("unix", abs) 63 | if err != nil { 64 | return nil, fmt.Errorf("Failed to resolv '%s' as a unix address: %v", abs, err) 65 | } 66 | listener, err := net.ListenUnix("unix", addr) 67 | if err != nil { 68 | return nil, fmt.Errorf("Failed to open socket at %s: %v", addr.Name, err) 69 | } 70 | return listener, nil 71 | } 72 | 73 | func parseCredentialsAddr(addr string) (*string, *string, error) { 74 | // Systemd stores metadata in its local unix address 75 | fields := strings.Split(addr, "/") 76 | if len(fields) != 4 || fields[1] != "unit" { 77 | return nil, nil, fmt.Errorf("Address needs to match this format: @/unit//, got '%s'", addr) 78 | } 79 | return &fields[2], &fields[3], nil 80 | } 81 | 82 | func (s *server) queueInotifyRequest(conn *net.UnixConn, filename string, key string) error { 83 | log.Printf("Block start until %s appears", filename) 84 | fd, err := connFd(conn) 85 | if err != nil { 86 | // connection was closed while we trying to wait 87 | return err 88 | } 89 | if err := s.epollWatch(fd); err != nil { 90 | log.Printf("Cannot get setup epoll for unix socket: %s", err) 91 | return err 92 | } 93 | s.inotifyRequests <- inotifyRequest{filename: filename, key: key, conn: conn} 94 | return nil 95 | } 96 | 97 | func (s *server) serveServiceEnvironment(conn *net.UnixConn, unit string, secret string) { 98 | shouldClose := true 99 | defer func() { 100 | if shouldClose { 101 | conn.Close() 102 | } 103 | }() 104 | 105 | log.Printf("Systemd requested environment file for %s from %s", secret, unit) 106 | secretPath := filepath.Join(s.SecretDir, secret) 107 | 108 | f, err := os.Open(secretPath) 109 | if errors.Is(err, os.ErrNotExist) { 110 | if s.queueInotifyRequest(conn, secret, secret) == nil { 111 | shouldClose = false 112 | } 113 | return 114 | } else if err != nil { 115 | log.Printf("Cannot open environment file %s/%s: %v", unit, secret, err) 116 | return 117 | } 118 | defer f.Close() 119 | if _, err = io.Copy(conn, f); err != nil { 120 | log.Printf("Failed to send environment file: %v", err) 121 | } 122 | } 123 | 124 | func (s *server) serveServiceSecrets(conn *net.UnixConn, unit string, secret string) { 125 | shouldClose := true 126 | defer func() { 127 | if shouldClose { 128 | conn.Close() 129 | } 130 | }() 131 | 132 | log.Printf("Systemd requested secret for %s/%s", unit, secret) 133 | secretName := unit + ".json" 134 | secretPath := filepath.Join(s.SecretDir, secretName) 135 | secretMap, err := parseServiceSecrets(secretPath) 136 | if errors.Is(err, os.ErrNotExist) { 137 | if s.queueInotifyRequest(conn, secretName, secret) == nil { 138 | shouldClose = false 139 | } 140 | return 141 | } else if err != nil { 142 | log.Printf("Cannot process secret %s/%s: %v", unit, secret, err) 143 | return 144 | } 145 | val, ok := secretMap[secret] 146 | if ok { 147 | if _, err = io.WriteString(conn, fmt.Sprint(val)); err != nil { 148 | log.Printf("Failed to send secret: %v", err) 149 | } 150 | } else { 151 | log.Printf("Secret map at %s has no value for key %s", secretPath, secret) 152 | } 153 | } 154 | 155 | func (s *server) serveConnection(conn *net.UnixConn) { 156 | addr := conn.RemoteAddr().String() 157 | unit, secret, err := parseCredentialsAddr(addr) 158 | if err != nil { 159 | conn.Close() 160 | log.Printf("Received connection but remote unix address seems to be not from systemd: %v", err) 161 | return 162 | } 163 | 164 | if isEnvironmentFile(*secret) { 165 | s.serveServiceEnvironment(conn, *unit, *secret) 166 | } else { 167 | s.serveServiceSecrets(conn, *unit, *secret) 168 | } 169 | } 170 | 171 | func serveSecrets(s *server) error { 172 | l, err := listenSocket(s.Socket) 173 | if err != nil { 174 | return fmt.Errorf("Failed to setup listening socket: %v", err) 175 | } 176 | defer l.Close() 177 | log.Printf("Listening on %s", s.Socket) 178 | go s.handleEpoll() 179 | for { 180 | conn, err := l.AcceptUnix() 181 | if err != nil { 182 | return fmt.Errorf("Error accepting unix connection: %v", err) 183 | } 184 | go s.serveConnection(conn) 185 | } 186 | } 187 | 188 | var secretDir, socketDir string 189 | 190 | func init() { 191 | defaultDir := os.Getenv("SYSTEMD_VAULT_SECRETS") 192 | if defaultDir == "" { 193 | defaultDir = "/run/systemd-vaultd/secrets" 194 | } 195 | flag.StringVar(&secretDir, "secrets", defaultDir, "directory where secrets are looked up") 196 | 197 | defaultSock := os.Getenv("SYSTEMD_VAULT_SOCK") 198 | if defaultSock == "" { 199 | defaultSock = "/run/systemd-vaultd/sock" 200 | } 201 | flag.StringVar(&socketDir, "sock", defaultSock, "unix socket to listen to for systemd requests") 202 | flag.Parse() 203 | } 204 | 205 | func createServer(secretDir string, socketDir string) (*server, error) { 206 | epfd, err := syscall.EpollCreate1(syscall.EPOLL_CLOEXEC) 207 | if epfd == -1 { 208 | return nil, fmt.Errorf("failed to create epoll fd: %v", err) 209 | } 210 | s := &server{ 211 | Socket: socketDir, 212 | SecretDir: secretDir, 213 | epfd: epfd, 214 | inotifyRequests: make(chan inotifyRequest), 215 | connectionClosed: make(chan int), 216 | } 217 | if err := s.setupWatcher(secretDir); err != nil { 218 | return nil, fmt.Errorf("Failed to setup file system watcher: %v", err) 219 | } 220 | return s, nil 221 | } 222 | 223 | func main() { 224 | s, err := createServer(secretDir, socketDir) 225 | if err != nil { 226 | log.Fatalf("Failed to create server: %v", err) 227 | } 228 | if err := serveSecrets(s); err != nil { 229 | log.Fatalf("Failed serve secrets: %v", err) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /nix/checks/dev-vault-server.nix: -------------------------------------------------------------------------------- 1 | { config 2 | , pkgs 3 | , ... 4 | }: { 5 | environment.systemPackages = [ pkgs.vault ]; 6 | services.vault = { 7 | enable = true; 8 | dev = true; 9 | devRootTokenID = "phony-secret"; 10 | }; 11 | environment.variables.VAULT_ADDR = "http://127.0.0.1:8200"; 12 | environment.variables.VAULT_TOKEN = config.services.vault.devRootTokenID; 13 | 14 | systemd.services.setup-vault-agent-approle = { 15 | path = [ pkgs.jq pkgs.vault pkgs.systemd ]; 16 | wantedBy = [ "multi-user.target" ]; 17 | 18 | serviceConfig = { 19 | Type = "oneshot"; 20 | RemainAfterExit = "yes"; 21 | Environment = [ 22 | "VAULT_TOKEN=${config.environment.variables.VAULT_TOKEN}" 23 | "VAULT_ADDR=${config.environment.variables.VAULT_ADDR}" 24 | ]; 25 | }; 26 | 27 | script = '' 28 | set -eux -o pipefail 29 | while ! vault status; do 30 | sleep 1 31 | done 32 | 33 | # capabilities of our vault agent 34 | cat > /tmp/policy-file.hcl < /tmp/roleID 46 | echo -n $(vault write -force -format json auth/approle/role/role1/secret-id | jq -r .data.secret_id) > /tmp/secretID 47 | ''; 48 | }; 49 | 50 | # Make sure our setup service is started before our vault-agent 51 | systemd.services.vault-agent-test = { 52 | wants = [ "setup-vault-agent-approle.service" ]; 53 | after = [ "setup-vault-agent-approle.service" ]; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /nix/checks/flake-module.nix: -------------------------------------------------------------------------------- 1 | { inputs, ... }: { 2 | imports = [ 3 | inputs.treefmt-nix.flakeModule 4 | ]; 5 | perSystem = 6 | { pkgs 7 | , ... 8 | }: { 9 | treefmt = { 10 | # Used to find the project root 11 | projectRootFile = "flake.lock"; 12 | 13 | programs.gofumpt.enable = true; 14 | programs.prettier.enable = true; 15 | 16 | settings.formatter = { 17 | nix = { 18 | command = "sh"; 19 | options = [ 20 | "-eucx" 21 | '' 22 | # First deadnix 23 | ${pkgs.lib.getExe pkgs.deadnix} --edit "$@" 24 | # Then nixpkgs-fmt 25 | ${pkgs.lib.getExe pkgs.nixpkgs-fmt} "$@" 26 | '' 27 | "--" 28 | ]; 29 | includes = [ "*.nix" ]; 30 | }; 31 | 32 | python = { 33 | command = "sh"; 34 | options = [ 35 | "-eucx" 36 | '' 37 | ${pkgs.lib.getExe pkgs.ruff} --fix "$@" 38 | ${pkgs.lib.getExe pkgs.python3.pkgs.black} "$@" 39 | '' 40 | "--" # this argument is ignored by bash 41 | ]; 42 | includes = [ "*.py" ]; 43 | }; 44 | }; 45 | }; 46 | 47 | checks = 48 | let 49 | nixosTests = pkgs.callPackages ./nixos-test.nix { 50 | makeTest = import (pkgs.path + "/nixos/tests/make-test-python.nix"); 51 | }; 52 | in 53 | { 54 | inherit (nixosTests) unittests vault-agent systemd-vaultd; 55 | }; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /nix/checks/nixos-test.nix: -------------------------------------------------------------------------------- 1 | { makeTest ? import 2 | , pkgs ? (import { }) 3 | , 4 | }: 5 | let 6 | makeTest' = args: 7 | makeTest args { 8 | inherit pkgs; 9 | inherit (pkgs) system; 10 | }; 11 | in 12 | { 13 | vault-agent = makeTest' (import ./vault-agent-test.nix); 14 | systemd-vaultd = makeTest' (import ./systemd-vaultd-test.nix); 15 | unittests = makeTest' { 16 | name = "unittests"; 17 | nodes.server = { 18 | imports = [ 19 | ../modules/systemd-vaultd.nix 20 | ]; 21 | }; 22 | 23 | testScript = '' 24 | start_all() 25 | server.succeed("machinectl shell .host ${pkgs.callPackage ./unittests.nix {}} >&2") 26 | # machinectl does not passthru exit codes, so we have to check manually 27 | server.succeed("[[ -f /tmp/success ]]") 28 | ''; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /nix/checks/systemd-vaultd-test.nix: -------------------------------------------------------------------------------- 1 | { 2 | name = "systemd-vaultd"; 3 | nodes.server = 4 | { config 5 | , ... 6 | }: { 7 | imports = [ 8 | ../modules/vault-agent.nix 9 | ../modules/systemd-vaultd.nix 10 | ./dev-vault-server.nix 11 | ]; 12 | # speed up tests 13 | virtualisation.cores = 4; 14 | virtualisation.memorySize = 1024; 15 | 16 | systemd.services.service1 = { 17 | wantedBy = [ "multi-user.target" ]; 18 | script = '' 19 | cat $CREDENTIALS_DIRECTORY/foo > /tmp/service1 20 | echo -n "$SECRET_ENV" > /tmp/service1-env 21 | ''; 22 | #serviceConfig = { 23 | # EnvironmentFile = [ "/run/systemd-vaultd/service1.service.EnvironmentFile" ]; 24 | #}; 25 | vault = { 26 | template = '' 27 | {{ with secret "secret/my-secret" }}{{ .Data.data | toJSON }}{{ end }} 28 | ''; 29 | secrets.foo = { }; 30 | environmentTemplate = '' 31 | {{ with secret "secret/my-secret" }} 32 | SECRET_ENV={{ .Data.data.foo }} 33 | {{ end }} 34 | ''; 35 | }; 36 | }; 37 | 38 | users.users.service2 = { 39 | isSystemUser = true; 40 | group = "service2"; 41 | uid = 1000; 42 | }; 43 | users.groups.service2.gid = 1000; 44 | 45 | systemd.services.service2 = { 46 | wantedBy = [ "multi-user.target" ]; 47 | preStart = '' 48 | cp -r $CREDENTIALS_DIRECTORY /run/service2/secrets 49 | ''; 50 | script = '' 51 | set -x 52 | while true; do 53 | cat /run/service2/secrets/secret >&2 || : 54 | cat /run/service2/secrets/secret > /tmp/service2 || : 55 | sleep 0.1 56 | done 57 | ''; 58 | serviceConfig = { 59 | ExecReload = "+${config.services.systemd-vaultd.package}/bin/systemd-vaultd-update-secrets /run/service2/secrets"; 60 | User = "service2"; 61 | Group = "service2"; 62 | LoadCredential = [ "secret:/run/systemd-vaultd/sock" ]; 63 | RuntimeDirectory = "service2"; 64 | }; 65 | vault = { 66 | template = '' 67 | {{ with secret "secret/blocking-secret" }}{{ scratch.MapSet "secrets" "secret" .Data.data.foo }}{{ end }} 68 | {{ scratch.Get "secrets" | explodeMap | toJSON }} 69 | ''; 70 | secrets.secret = { }; 71 | }; 72 | }; 73 | 74 | services.vault.agents.default.settings = { 75 | vault = { 76 | address = "http://localhost:8200"; 77 | }; 78 | auto_auth = { 79 | method = [ 80 | { 81 | type = "approle"; 82 | config = { 83 | role_id_file_path = "/tmp/roleID"; 84 | secret_id_file_path = "/tmp/secretID"; 85 | remove_secret_id_file_after_reading = false; 86 | }; 87 | } 88 | ]; 89 | }; 90 | }; 91 | }; 92 | testScript = '' 93 | start_all() 94 | machine.wait_for_unit("vault.service") 95 | machine.wait_for_open_port(8200) 96 | machine.wait_for_unit("setup-vault-agent-approle.service") 97 | machine.wait_for_unit("vault-agent-default.service") 98 | 99 | out = machine.wait_until_succeeds("grep -q bar /tmp/service1") 100 | 101 | out = machine.succeed("grep -q bar /tmp/service1-env") 102 | 103 | out = machine.succeed("systemctl status service2 || :") 104 | print(out) 105 | assert "(sd-mkdcreds)" in out, "service2 should be still blocked" 106 | 107 | machine.succeed("vault kv put secret/blocking-secret foo=bar") 108 | machine.wait_until_succeeds("grep -q bar /tmp/service2 >&2") 109 | 110 | machine.succeed("umount /run/credentials/service2.service") 111 | machine.succeed("rm /run/systemd-vaultd/secrets/service2.service.json") 112 | 113 | machine.succeed("vault kv put secret/blocking-secret foo=reload") 114 | 115 | machine.succeed("systemctl restart vault-agent-default") 116 | machine.wait_until_succeeds("cat /run/systemd-vaultd/secrets/service2.service.json >&2") 117 | machine.succeed("systemctl restart service2") 118 | 119 | machine.succeed("rm /tmp/service2") 120 | machine.wait_until_succeeds("grep -q reload /tmp/service2 >&2") 121 | 122 | # get uid and gid 123 | out = machine.succeed("stat -c %u /run/service2/secrets/secret").strip() 124 | assert out == "1000", "service2 should have access to secret file with uid 1000, got " + out 125 | out = machine.succeed("stat -c %g /run/service2/secrets/secret").strip() 126 | assert out == "1000", "service2 should have access to secret file with gid 1000, got " + out 127 | 128 | # get permissions in octal 129 | out = machine.succeed("stat -c %a /run/service2/secrets/secret").strip() 130 | assert out == "400", "service2 should have access to secret file with permissions 0400, got " + out 131 | ''; 132 | } 133 | -------------------------------------------------------------------------------- /nix/checks/unittests.nix: -------------------------------------------------------------------------------- 1 | { writeShellScript 2 | , python3 3 | , pkgs 4 | , lib 5 | , coreutils 6 | , systemd 7 | }: 8 | let 9 | systemd-vaultd = pkgs.callPackage ../../default.nix { }; 10 | in 11 | writeShellScript "unittests" '' 12 | set -eu -o pipefail 13 | export PATH=${lib.makeBinPath [python3.pkgs.pytest coreutils systemd]} 14 | export SYSTEMD_VAULTD_BIN=${systemd-vaultd}/bin/systemd-vaultd 15 | export TMPDIR=$(mktemp -d) 16 | trap 'rm -rf $TMPDIR' EXIT 17 | cp --no-preserve=mode --preserve=timestamps -r ${../..} "$TMPDIR/source" 18 | cd "$TMPDIR/source" 19 | pytest -s ./tests 20 | # we need this in our nixos tests 21 | touch /tmp/success 22 | '' 23 | -------------------------------------------------------------------------------- /nix/checks/vault-agent-test.nix: -------------------------------------------------------------------------------- 1 | { 2 | name = "vault-agent"; 3 | nodes.server = 4 | { config 5 | , ... 6 | }: { 7 | imports = [ 8 | ./dev-vault-server.nix 9 | ../modules/vault-agent.nix 10 | ]; 11 | 12 | services.vault.agents.test.settings = { 13 | vault = { 14 | address = "http://localhost:8200"; 15 | }; 16 | template = { 17 | contents = ''{{ with secret "secret/my-secret" }}{{ .Data.data.foo }}{{ end }}''; 18 | destination = "/run/render.txt"; 19 | }; 20 | 21 | auto_auth = { 22 | method = [ 23 | { 24 | type = "approle"; 25 | config = { 26 | role_id_file_path = "/tmp/roleID"; 27 | secret_id_file_path = "/tmp/secretID"; 28 | remove_secret_id_file_after_reading = false; 29 | }; 30 | } 31 | ]; 32 | }; 33 | }; 34 | }; 35 | testScript = '' 36 | start_all() 37 | machine.wait_for_unit("multi-user.target") 38 | machine.wait_for_unit("vault.service") 39 | machine.wait_for_open_port(8200) 40 | machine.wait_for_unit("setup-vault-agent-approle.service") 41 | 42 | # It should be able to write our template 43 | out = machine.wait_until_succeeds("cat /run/render.txt") 44 | print(out) 45 | assert out == "bar" 46 | ''; 47 | } 48 | -------------------------------------------------------------------------------- /nix/modules/systemd-vaultd.nix: -------------------------------------------------------------------------------- 1 | { pkgs 2 | , lib 3 | , config 4 | , ... 5 | }: 6 | let 7 | systemd-vaultd = pkgs.callPackage ../../default.nix { }; 8 | in 9 | { 10 | imports = [ 11 | ./vault-secrets.nix 12 | ]; 13 | options = { 14 | services.systemd-vaultd = { 15 | package = lib.mkOption { 16 | type = lib.types.package; 17 | default = systemd-vaultd; 18 | defaultText = "pkgs.systemd-vaultd"; 19 | description = '' 20 | The package to use for systemd-vaultd 21 | ''; 22 | }; 23 | }; 24 | }; 25 | 26 | config = { 27 | systemd.sockets.systemd-vaultd = { 28 | description = "systemd-vaultd socket"; 29 | wantedBy = [ "sockets.target" ]; 30 | 31 | socketConfig = { 32 | ListenStream = "/run/systemd-vaultd/sock"; 33 | SocketUser = "root"; 34 | SocketMode = "0600"; 35 | }; 36 | }; 37 | systemd.services.systemd-vaultd = { 38 | description = "systemd-vaultd daemon"; 39 | requires = [ "systemd-vaultd.socket" ]; 40 | after = [ "systemd-vaultd.socket" ]; 41 | # Restarting can break services waiting for secrets 42 | stopIfChanged = false; 43 | serviceConfig = { 44 | ExecStart = "${config.services.systemd-vaultd.package}/bin/systemd-vaultd"; 45 | }; 46 | }; 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /nix/modules/vault-agent.nix: -------------------------------------------------------------------------------- 1 | { config 2 | , lib 3 | , pkgs 4 | , ... 5 | }: 6 | let 7 | cfg = config.services.vault; 8 | settingsFormat = pkgs.formats.json { }; 9 | 10 | autoAuthMethodModule = lib.types.submodule { 11 | freeformType = lib.types.attrsOf lib.types.unspecified; 12 | 13 | options = { 14 | type = lib.mkOption { 15 | type = lib.types.str; 16 | }; 17 | 18 | config = lib.mkOption { 19 | type = lib.types.attrsOf lib.types.unspecified; 20 | }; 21 | }; 22 | }; 23 | 24 | autoAuthModule = lib.types.submodule { 25 | freeformType = lib.types.attrsOf lib.types.unspecified; 26 | 27 | options = { 28 | method = lib.mkOption { 29 | type = lib.types.listOf autoAuthMethodModule; 30 | default = [ ]; 31 | }; 32 | }; 33 | }; 34 | 35 | templateConfigModule = lib.types.submodule { 36 | freeformType = lib.types.attrsOf lib.types.unspecified; 37 | 38 | options = { 39 | exit_on_retry_failure = lib.mkOption { 40 | type = lib.types.bool; 41 | default = true; 42 | }; 43 | }; 44 | }; 45 | 46 | agentConfigType = lib.types.submodule { 47 | freeformType = lib.types.attrsOf lib.types.unspecified; 48 | 49 | options = { 50 | auto_auth = lib.mkOption { 51 | type = autoAuthModule; 52 | default = { }; 53 | }; 54 | 55 | template_config = lib.mkOption { 56 | type = templateConfigModule; 57 | default = { }; 58 | }; 59 | }; 60 | }; 61 | in 62 | { 63 | options.services.vault.agents = lib.mkOption { 64 | default = { }; 65 | description = "Instances of vault agent"; 66 | type = lib.types.attrsOf (lib.types.submodule { 67 | options = { 68 | settings = lib.mkOption { 69 | description = "agent configuration"; 70 | type = agentConfigType; 71 | }; 72 | }; 73 | }); 74 | }; 75 | config = { 76 | systemd.services = lib.mapAttrs' 77 | (name: instanceCfg: 78 | lib.nameValuePair "vault-agent-${name}" { 79 | after = [ "network.target" ]; 80 | wantedBy = [ "multi-user.target" ]; 81 | 82 | # Services that also have `stopIfChanged = false` might wait for secrets 83 | # while `vault-agent` is still stopped. This for example happens with nginx.service. 84 | 85 | stopIfChanged = false; 86 | # Needs getent in PATH 87 | path = [ pkgs.getent ]; 88 | serviceConfig = { 89 | Restart = "on-failure"; 90 | ExecStart = "${pkgs.vault}/bin/vault agent -config=${settingsFormat.generate "agent.json" instanceCfg.settings}"; 91 | }; 92 | }) 93 | cfg.agents; 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /nix/modules/vault-secrets.nix: -------------------------------------------------------------------------------- 1 | { lib 2 | , config 3 | , pkgs 4 | , ... 5 | }: 6 | let 7 | secretType = serviceName: 8 | lib.types.submodule ({ config, ... }: { 9 | options = { 10 | name = lib.mkOption { 11 | type = lib.types.str; 12 | default = config._module.args.name; 13 | description = '' 14 | Name of the secret used in LoadCredential 15 | ''; 16 | }; 17 | path = lib.mkOption { 18 | type = lib.types.str; 19 | default = "/run/credentials/${serviceName}.service/${config.name}"; 20 | defaultText = "/run/credentials/$service.service/$name"; 21 | description = '' 22 | Absolute path to systemd's loaded credentials. 23 | WARNING: Using this path might break if systemd in future decides to use 24 | a different location but /run/credentials 25 | ''; 26 | }; 27 | }; 28 | }); 29 | 30 | services = config.systemd.services; 31 | 32 | templateExec = serviceName: vaultConfig: { } // 33 | lib.optionalAttrs (vaultConfig.changeAction != null && vaultConfig.changeAction != "none") { 34 | exec = [ 35 | ({ 36 | command = "systemctl ${ 37 | if vaultConfig.changeAction == "restart" 38 | then "try-restart" 39 | else "try-reload-or-restart" 40 | } ${lib.escapeShellArg "${serviceName}.service"}"; 41 | } // lib.optionalAttrs 42 | (vaultConfig.command_timeout != null) 43 | { timeout = vaultConfig.command_timeout; }) 44 | ]; 45 | }; 46 | 47 | getSecretTemplate = serviceName: vaultConfig: 48 | { 49 | contents = vaultConfig.template; 50 | destination = "/run/systemd-vaultd/secrets/${serviceName}.service.json"; 51 | perms = "0400"; 52 | } 53 | // templateExec serviceName vaultConfig; 54 | 55 | getEnvironmentTemplate = serviceName: vaultConfig: 56 | { 57 | contents = vaultConfig.environmentTemplate; 58 | destination = "/run/systemd-vaultd/secrets/${serviceName}.service.EnvironmentFile"; 59 | perms = "0400"; 60 | } 61 | // templateExec serviceName vaultConfig; 62 | 63 | vaultTemplates = config: 64 | (lib.mapAttrsToList 65 | (serviceName: _service: 66 | getSecretTemplate serviceName services.${serviceName}.vault) 67 | (lib.filterAttrs (_n: v: v.vault.template != null && v.vault.agent == config._module.args.name) services)) 68 | ++ (lib.mapAttrsToList 69 | (serviceName: _service: 70 | getEnvironmentTemplate serviceName services.${serviceName}.vault) 71 | (lib.filterAttrs (_n: v: v.vault.environmentTemplate != null && v.vault.agent == config._module.args.name) services)); 72 | in 73 | { 74 | options = { 75 | systemd.services = lib.mkOption { 76 | type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: 77 | let 78 | serviceName = config._module.args.name; 79 | in 80 | { 81 | options.vault = { 82 | changeAction = lib.mkOption { 83 | description = '' 84 | What to do with the service if any secrets change 85 | ''; 86 | type = lib.types.nullOr (lib.types.enum [ 87 | "none" 88 | "reload-or-restart" 89 | "restart" 90 | ]); 91 | default = "reload-or-restart"; 92 | }; 93 | 94 | template = lib.mkOption { 95 | type = lib.types.nullOr lib.types.lines; 96 | default = null; 97 | description = '' 98 | The vault agent template to use for secrets 99 | ''; 100 | }; 101 | 102 | environmentTemplate = lib.mkOption { 103 | type = lib.types.nullOr lib.types.lines; 104 | default = null; 105 | description = '' 106 | The vault agent template to use for environment file 107 | ''; 108 | }; 109 | 110 | agent = lib.mkOption { 111 | type = lib.types.str; 112 | default = "default"; 113 | description = '' 114 | Agent instance to use for this service 115 | ''; 116 | }; 117 | 118 | secrets = lib.mkOption { 119 | type = lib.types.attrsOf (secretType serviceName); 120 | default = { }; 121 | description = "List of secrets to load from vault agent template"; 122 | example = { 123 | some-secret.template = ''{{ with secret "secret/some-secret" }}{{ .Data.data.some-key }}{{ end }}''; 124 | }; 125 | }; 126 | 127 | command_timeout = lib.mkOption { 128 | type = lib.types.nullOr lib.types.str; 129 | default = null; 130 | description = '' 131 | Maximum amount of time to wait for the optional command to return. 132 | ''; 133 | }; 134 | 135 | }; 136 | config = 137 | let 138 | mkIfHasEnv = lib.mkIf (config.vault.environmentTemplate != null); 139 | mkIfHasSecret = lib.mkIf (config.vault.template != null); 140 | in 141 | { 142 | after = mkIfHasEnv [ "${serviceName}-envfile.service" ]; 143 | bindsTo = mkIfHasEnv [ "${serviceName}-envfile.service" ]; 144 | 145 | serviceConfig = { 146 | LoadCredential = mkIfHasSecret (lib.mapAttrsToList (_: config: "${config.name}:/run/systemd-vaultd/sock") config.vault.secrets); 147 | EnvironmentFile = mkIfHasEnv [ "/run/systemd-vaultd/secrets/${serviceName}.service.EnvironmentFile" ]; 148 | }; 149 | }; 150 | })); 151 | }; 152 | 153 | services.vault.agents = lib.mkOption { 154 | type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: { 155 | config.settings.template = vaultTemplates config; 156 | })); 157 | }; 158 | }; 159 | 160 | config = { 161 | # we cannot use `systemd.services` here since this would create infinite recursion 162 | systemd.packages = 163 | let 164 | servicesWithEnv = builtins.attrNames (lib.filterAttrs (_n: v: v.vault.environmentTemplate != null) services); 165 | in 166 | [ 167 | (pkgs.runCommand "env-services" { } 168 | ('' 169 | mkdir -p $out/lib/systemd/system 170 | '' 171 | + (lib.concatMapStringsSep "\n" 172 | (service: '' 173 | cat > $out/lib/systemd/system/${service}-envfile.service < 0 { 46 | name = names[offset] 47 | } 48 | files = append(files, os.NewFile(uintptr(fd), name)) 49 | } 50 | 51 | return files 52 | } 53 | -------------------------------------------------------------------------------- /tests/agent-config.hcl: -------------------------------------------------------------------------------- 1 | pid_file = "./pidfile" 2 | 3 | auto_auth { 4 | method { 5 | type = "aws" 6 | namespace = "/my-namespace" 7 | config = { 8 | role = "foobar" 9 | } 10 | } 11 | 12 | sink { 13 | type = "file" 14 | config = { 15 | path = "/tmp/file-foo" 16 | } 17 | aad = "foobar" 18 | dh_type = "curve25519" 19 | dh_path = "/tmp/file-foo-dhpath" 20 | } 21 | 22 | sink { 23 | type = "file" 24 | wrap_ttl = "5m" 25 | aad_env_var = "TEST_AAD_ENV" 26 | dh_type = "curve25519" 27 | dh_path = "/tmp/file-foo-dhpath2" 28 | derive_key = true 29 | config = { 30 | path = "/tmp/file-bar" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import signal 5 | import subprocess 6 | from pathlib import Path 7 | from typing import IO, Any, Dict, Iterator, List, Union 8 | 9 | import pytest 10 | 11 | _DIR = Union[None, Path, str] 12 | _FILE = Union[None, int, IO[Any]] 13 | 14 | 15 | def run( 16 | cmd: List[str], 17 | text: bool = True, 18 | check: bool = True, 19 | cwd: _DIR = None, 20 | stderr: _FILE = None, 21 | stdout: _FILE = None, 22 | ) -> subprocess.CompletedProcess: 23 | if cwd is not None: 24 | print(f"cd {cwd}") 25 | print("$ " + " ".join(cmd)) 26 | return subprocess.run( 27 | cmd, text=text, check=check, cwd=cwd, stderr=stderr, stdout=stdout 28 | ) 29 | 30 | 31 | class Command: 32 | def __init__(self) -> None: 33 | self.processes: List[subprocess.Popen] = [] 34 | 35 | def run( 36 | self, 37 | command: List[str], 38 | extra_env: Dict[str, str] = {}, 39 | stdin: _FILE = None, 40 | stdout: _FILE = None, 41 | stderr: _FILE = None, 42 | text: bool = True, 43 | ) -> subprocess.Popen: 44 | env = os.environ.copy() 45 | env.update(extra_env) 46 | # We start a new session here so that we can than more reliably kill all childs as well 47 | p = subprocess.Popen( 48 | command, 49 | env=env, 50 | start_new_session=True, 51 | stdout=stdout, 52 | stderr=stderr, 53 | stdin=stdin, 54 | text=text, 55 | ) 56 | self.processes.append(p) 57 | return p 58 | 59 | def terminate(self) -> None: 60 | # Stop in reverse order in case there are dependencies. 61 | # We just kill all processes as quickly as possible because we don't 62 | # care about corrupted state and want to make tests fasts. 63 | for p in reversed(self.processes): 64 | try: 65 | os.killpg(os.getpgid(p.pid), signal.SIGKILL) 66 | except OSError: 67 | pass 68 | 69 | 70 | @pytest.fixture 71 | def command() -> Iterator[Command]: 72 | """ 73 | Starts a background command. The process is automatically terminated in the end. 74 | >>> p = command.run(["some", "daemon"]) 75 | >>> print(p.pid) 76 | """ 77 | c = Command() 78 | try: 79 | yield c 80 | finally: 81 | c.terminate() 82 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | pytest_plugins = [ 4 | "command", 5 | "root", 6 | "systemd_vaultd", 7 | "tempdir", 8 | ] 9 | -------------------------------------------------------------------------------- /tests/random_service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import random 5 | import string 6 | from dataclasses import dataclass 7 | from pathlib import Path 8 | 9 | 10 | def rand_word(n: int) -> str: 11 | return "".join(random.choices(string.ascii_uppercase + string.digits, k=n)) 12 | 13 | 14 | @dataclass 15 | class Service: 16 | name: str 17 | secret_name: str 18 | secret_path: Path 19 | 20 | def write_secret(self, val: str) -> None: 21 | tmp = self.secret_path.with_name(self.secret_path.name + ".tmp") 22 | tmp.write_text(json.dumps({self.secret_name: val})) 23 | tmp.rename(self.secret_path) 24 | 25 | 26 | def random_service(secrets_dir: Path) -> Service: 27 | service = f"test-service-{rand_word(8)}.service" 28 | secret_name = "foo" 29 | secret = f"{service}.json" 30 | secret_path = secrets_dir / secret 31 | return Service(service, secret_name, secret_path) 32 | -------------------------------------------------------------------------------- /tests/root.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | TEST_ROOT = Path(__file__).parent.resolve() 8 | PROJECT_ROOT = TEST_ROOT.parent 9 | 10 | 11 | @pytest.fixture 12 | def test_root() -> Path: 13 | """ 14 | Root directory of the tests 15 | """ 16 | return TEST_ROOT 17 | 18 | 19 | @pytest.fixture 20 | def project_root() -> Path: 21 | """ 22 | Root directory of the tests 23 | """ 24 | return PROJECT_ROOT 25 | -------------------------------------------------------------------------------- /tests/setup-vault: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux -o pipefail 3 | export VAULT_ADDR=http://127.0.0.1:8200 4 | export VAULT_TOKEN=secret 5 | 6 | while ! vault status; do 7 | sleep 1 8 | done 9 | 10 | mkdir -p tmp 11 | 12 | # capabilities of our vault agent 13 | cat > tmp/policy-file.hcl < tmp/roleID 25 | echo -n $(vault write -force -format json auth/approle/role/role1/secret-id | jq -r .data.secret_id) > tmp/secretID 26 | -------------------------------------------------------------------------------- /tests/systemd_vaultd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | import pytest 8 | from command import run 9 | 10 | BIN: Optional[Path] = None 11 | 12 | 13 | @pytest.fixture 14 | def systemd_vaultd(project_root: Path) -> Path: 15 | global BIN 16 | if BIN: 17 | return BIN 18 | bin = os.environ.get("SYSTEMD_VAULTD_BIN") 19 | if bin: 20 | BIN = Path(bin) 21 | return BIN 22 | run(["go", "build", str(project_root)]) 23 | BIN = project_root / "systemd-vaultd" 24 | return BIN 25 | -------------------------------------------------------------------------------- /tests/tempdir.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from pathlib import Path 4 | from tempfile import TemporaryDirectory 5 | from typing import Iterator 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture 11 | def tempdir() -> Iterator[Path]: 12 | with TemporaryDirectory() as dir: 13 | yield Path(dir) 14 | -------------------------------------------------------------------------------- /tests/test_blocking_secret.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import time 3 | from pathlib import Path 4 | 5 | from command import Command 6 | from random_service import random_service 7 | 8 | 9 | def test_blocking_secret(systemd_vaultd: Path, command: Command, tempdir: Path) -> None: 10 | secrets_dir = tempdir / "secrets" 11 | sock = tempdir / "sock" 12 | command.run([str(systemd_vaultd), "-secrets", str(secrets_dir), "-sock", str(sock)]) 13 | 14 | while not sock.exists(): 15 | time.sleep(0.1) 16 | 17 | service = random_service(secrets_dir) 18 | 19 | proc = command.run( 20 | [ 21 | "systemd-run", 22 | "-u", 23 | service.name, 24 | "--collect", 25 | "--user", 26 | "-p", 27 | f"LoadCredential={service.secret_name}:{sock}", 28 | "--wait", 29 | "--pipe", 30 | "cat", 31 | "${CREDENTIALS_DIRECTORY}/" + service.secret_name, 32 | ], 33 | stdout=subprocess.PIPE, 34 | ) 35 | time.sleep(0.1) 36 | assert proc.poll() is None, "service should block for secret" 37 | service.write_secret("foo") 38 | assert proc.stdout is not None and proc.stdout.read() == "foo" 39 | assert proc.wait() == 0 40 | -------------------------------------------------------------------------------- /tests/test_socket_activation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | import time 5 | from pathlib import Path 6 | 7 | from command import Command, run 8 | from random_service import random_service 9 | 10 | 11 | def test_socket_activation( 12 | systemd_vaultd: Path, 13 | command: Command, 14 | tempdir: Path, 15 | ) -> None: 16 | secrets_dir = tempdir / "secrets" 17 | secrets_dir.mkdir() 18 | sock = tempdir / "sock" 19 | 20 | command.run( 21 | [ 22 | "systemd-socket-activate", 23 | "--listen", 24 | str(sock), 25 | str(systemd_vaultd), 26 | "-secrets", 27 | str(secrets_dir), 28 | "-sock", 29 | str(sock), 30 | ] 31 | ) 32 | 33 | while not sock.exists(): 34 | time.sleep(0.1) 35 | 36 | service = random_service(secrets_dir) 37 | service.write_secret("foo") 38 | 39 | # should not block 40 | out = run( 41 | [ 42 | "systemd-run", 43 | "-u", 44 | service.name, 45 | "--collect", 46 | "--user", 47 | "-p", 48 | f"LoadCredential={service.secret_name}:{sock}", 49 | "--wait", 50 | "--pipe", 51 | "cat", 52 | "${CREDENTIALS_DIRECTORY}/" + service.secret_name, 53 | ], 54 | stdout=subprocess.PIPE, 55 | ) 56 | assert out.stdout == "foo" 57 | assert out.returncode == 0 58 | -------------------------------------------------------------------------------- /tests/test_vault.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # from command import Command, run 4 | # from pathlib import Path 5 | 6 | # def test_blocking_secret( 7 | # systemd_vaultd: Path, command: Command, tempdir: Path 8 | # ) -> None: 9 | # secrets_dir = tempdir / "secrets" 10 | # command.run(["vault", "server", "-dev"]) 11 | # sock = tempdir / "sock" 12 | # command.run([str(systemd_vaultd), "-secrets", str(secrets_dir), "-sock", str(sock)]) 13 | 14 | # while not sock.exists(): 15 | # time.sleep(0.1) 16 | 17 | # service = random_service(secrets_dir) 18 | 19 | # proc = command.run( 20 | # [ 21 | # "systemd-run", 22 | # "-u", 23 | # service.name, 24 | # "--collect", 25 | # "--user", 26 | # "-p", 27 | # f"LoadCredential={service.secret_name}:{sock}", 28 | # "--wait", 29 | # "--pipe", 30 | # "cat", 31 | # "${CREDENTIALS_DIRECTORY}/" + service.secret_name, 32 | # ], 33 | # stdout=subprocess.PIPE, 34 | # ) 35 | # time.sleep(0.1) 36 | # assert proc.poll() is None, "service should block for secret" 37 | # service.secret_path.write_text("foo") 38 | # assert proc.stdout is not None and proc.stdout.read() == "foo" 39 | # assert proc.wait() == 0 40 | -------------------------------------------------------------------------------- /tests/vault-agent-example.hcl: -------------------------------------------------------------------------------- 1 | vault = { 2 | address = "http://localhost:8200" 3 | } 4 | template = { 5 | contents = "{{ with secret \"secret/my-secret\" }}{{ .Data.data.foo }}{{ end }}" 6 | destination = "tmp/secrets/vault-nixos3.service-foo" 7 | } 8 | 9 | auto_auth { 10 | method { 11 | type = "approle" 12 | config = { 13 | role_id_file_path = "tmp/roleID" 14 | secret_id_file_path = "tmp/secretID" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /watcher.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "syscall" 13 | "unsafe" 14 | ) 15 | 16 | type inotifyRequest struct { 17 | filename string 18 | key string 19 | conn *net.UnixConn 20 | } 21 | 22 | type connection struct { 23 | fd int 24 | key string 25 | connection *net.UnixConn 26 | } 27 | 28 | func readEvents(inotifyFd int, events chan string) { 29 | defer syscall.Close(inotifyFd) 30 | var buf [syscall.SizeofInotifyEvent * 4096]byte // Buffer for a maximum of 4096 raw events 31 | for { 32 | n, err := syscall.Read(inotifyFd, buf[:]) 33 | // If a signal interrupted execution, see if we've been asked to close, and try again. 34 | // http://man7.org/linux/man-pages/man7/signal.7.html : 35 | // "Before Linux 3.8, reads from an inotify(7) file descriptor were not restartable" 36 | if errors.Is(err, syscall.EINTR) { 37 | continue 38 | } 39 | 40 | if n < syscall.SizeofInotifyEvent { 41 | if n == 0 { 42 | log.Fatalf("notify: read EOF from inotify (cause: %v)", err) 43 | } else if n < 0 { 44 | log.Fatalf("notify: Received error while reading from inotify: %v", err) 45 | } else { 46 | log.Fatal("notify: short read in readEvents()") 47 | } 48 | continue 49 | } 50 | var offset uint32 51 | for offset+syscall.SizeofInotifyEvent <= uint32(n) { 52 | // Point "raw" to the event in the buffer 53 | raw := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset])) 54 | 55 | mask := uint32(raw.Mask) 56 | nameLen := uint32(raw.Len) 57 | 58 | if mask&syscall.IN_Q_OVERFLOW != 0 { 59 | // TODO Re-scan all files in this case 60 | log.Fatal("Overflow in inotify") 61 | } 62 | if nameLen > 0 { 63 | // Point "bytes" at the first byte of the filename 64 | bytes := (*[syscall.PathMax]byte)(unsafe.Pointer(&buf[uintptr(offset)+unsafe.Offsetof(raw.Name)])) 65 | // The filename is padded with NULL bytes. TrimRight() gets rid of those. 66 | fname := strings.TrimRight(string(bytes[0:nameLen]), "\000") 67 | log.Printf("Detected added file: %s", fname) 68 | events <- fname 69 | } else { 70 | log.Printf("file added without length!?") 71 | } 72 | 73 | // Move to the next event in the buffer 74 | offset += syscall.SizeofInotifyEvent + nameLen 75 | } 76 | } 77 | } 78 | 79 | func connFd(conn *net.UnixConn) (int, error) { 80 | file, err := conn.File() 81 | if err != nil { 82 | return -1, err 83 | } 84 | return int(file.Fd()), nil 85 | } 86 | 87 | func (s *server) watch(inotifyFd int) { 88 | connsForPath := make(map[string][]connection) 89 | fdToPath := make(map[int]string) 90 | 91 | fsEvents := make(chan string) 92 | go readEvents(inotifyFd, fsEvents) 93 | for { 94 | select { 95 | case req, ok := <-s.inotifyRequests: 96 | if !ok { 97 | return 98 | } 99 | fd, err := connFd(req.conn) 100 | if err != nil { 101 | log.Println("Received inotify request for closed connection") 102 | continue 103 | } 104 | fdToPath[fd] = req.filename 105 | conns, ok := connsForPath[req.filename] 106 | if ok { 107 | connsForPath[req.filename] = append(conns, connection{fd, req.key, req.conn}) 108 | continue 109 | } 110 | 111 | connsForPath[req.filename] = []connection{{fd, req.key, req.conn}} 112 | case fname, ok := <-fsEvents: 113 | if !ok { 114 | return 115 | } 116 | conns := connsForPath[fname] 117 | if conns == nil { 118 | log.Printf("Ignore unknown file: %s", fname) 119 | continue 120 | } 121 | delete(connsForPath, fname) 122 | 123 | var secretMap map[string]interface{} 124 | var err error 125 | 126 | if isEnvironmentFile(fname) { 127 | content, err := os.ReadFile(filepath.Join(s.SecretDir, fname)) 128 | if err != nil { 129 | log.Printf("Failed to process service file: %v", err) 130 | continue 131 | } 132 | secretMap = map[string]interface{}{fname: string(content)} 133 | } else { 134 | secretMap, err = parseServiceSecrets(filepath.Join(s.SecretDir, fname)) 135 | if err != nil { 136 | log.Printf("Failed to process service file: %v", err) 137 | continue 138 | } 139 | } 140 | 141 | for _, conn := range conns { 142 | defer delete(fdToPath, conn.fd) 143 | 144 | if err == nil { 145 | val, ok := secretMap[conn.key] 146 | if !ok { 147 | log.Printf("Secret map %s has no value for key %s", fname, conn.key) 148 | continue 149 | } 150 | _, err = io.WriteString(conn.connection, fmt.Sprint(val)) 151 | if err == nil { 152 | log.Printf("Served %s to %s", fname, conn.connection.RemoteAddr().String()) 153 | } else { 154 | log.Printf("Failed to send secret: %v", err) 155 | } 156 | if err := s.epollDelete(conn.fd); err != nil && !errors.Is(err, syscall.ENOENT) { 157 | log.Printf("failed to remove socket from epoll: %s", err) 158 | } 159 | if err := syscall.Shutdown(conn.fd, syscall.SHUT_RDWR); err != nil { 160 | log.Printf("Failed to shutdown socket: %v", err) 161 | } 162 | } else { 163 | log.Printf("Failed to open secret: %v", err) 164 | } 165 | } 166 | case fd, ok := <-s.connectionClosed: 167 | if !ok { 168 | return 169 | } 170 | path := fdToPath[fd] 171 | delete(fdToPath, fd) 172 | conns := connsForPath[path] 173 | if conns == nil { 174 | // watcher has been already deregistered 175 | continue 176 | } 177 | for idx, c := range conns { 178 | if c.fd == fd { 179 | last := len(conns) - 1 180 | conns[idx] = conns[last] 181 | conns = conns[:last] 182 | 183 | c.connection.Close() 184 | break 185 | } 186 | } 187 | if len(conns) == 0 { 188 | delete(connsForPath, path) 189 | } 190 | } 191 | } 192 | } 193 | 194 | func (s *server) setupWatcher(dir string) error { 195 | fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) 196 | if err != nil { 197 | return fmt.Errorf("Failed to initialize inotify: %v", err) 198 | } 199 | flags := uint32(syscall.IN_CREATE | syscall.IN_MOVED_TO | syscall.IN_ONLYDIR) 200 | 201 | // Allow processes to read files from this directory if they have the 202 | // permissions on the files, but don't allow them to list files in it. 203 | res := os.MkdirAll(dir, 0o711) 204 | if err != nil && !os.IsNotExist(res) { 205 | return fmt.Errorf("Failed to create secret directory: %v", err) 206 | } 207 | if _, err = syscall.InotifyAddWatch(fd, dir, flags); err != nil { 208 | return fmt.Errorf("Failed to initialize inotify on secret directory %s: %v", dir, err) 209 | } 210 | go s.watch(fd) 211 | return nil 212 | } 213 | --------------------------------------------------------------------------------