From 194336d1d0df4c10861633d69ac805ad691c8a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Thu, 27 Oct 2022 11:57:26 +0200 Subject: [PATCH] systemd-vaultd: switch to use a json file for reading files --- README.md | 25 +++++++++++++++++++++---- main.go | 21 +++++++++++++-------- nix/checks/nixos-test.nix | 19 ++++++++++++------- secrets.go | 20 ++++++++++++++++++++ tests/random_service.py | 8 +++++++- tests/test_blocking_secret.py | 2 +- tests/test_socket_activation.py | 2 +- watcher.go | 21 +++++++++++++++------ 8 files changed, 90 insertions(+), 28 deletions(-) create mode 100644 secrets.go diff --git a/README.md b/README.md index c34f952..7098710 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ > Mostly written in a train - Jörg Thalheim +systemd-vaultd is a proxy between systemd and [vault agent](https://vaultproject.io). +It provides a unix socket that can be used in systemd services in the +`LoadCredential` option and then waits for vault agent to write these secrets in +json format at `/run/systemd-vaultd/.service.json`. + This project's goal is to simplify the loading of [HashiCorp Vault](https://www.vaultproject.io/) secrets from [systemd](https://systemd.io/) units. @@ -50,20 +55,32 @@ ExecStart=/usr/bin/myservice.sh LoadCredential=foobar:/run/systemd-vaultd/sock ``` -vault agent is then expected to write secrets to `/run/systemd-vaultd/` +vault agent is then expected to write secrets to `/run/systemd-vaultd/` in json format. ``` template { - contents = "{{ with secret \"secret/my-secret\" }}{{ .Data.data.foo }}{{ end }}" - destination = "/run/systemd-vaultd/secrets/myservice.service-foo" + # this exposes all secrets in `secret/my-secret` to the service + contents = "#{{ with secret \"secret/my-secret\" }}{{ .Data.data | toJSON }}{{ end }}" + + # an alternative is to expose only selected secrets like this: + # contents = < /tmp/service1 + cat $CREDENTIALS_DIRECTORY/foo > /tmp/service1 ''; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; - LoadCredential = ["secret:/run/systemd-vaultd/sock"]; + LoadCredential = ["foo:/run/systemd-vaultd/sock"]; }; }; @@ -100,12 +100,17 @@ in { }; template = [ { - contents = ''{{ with secret "secret/my-secret" }}{{ .Data.data.foo }}{{ end }}''; - destination = "/run/systemd-vaultd/secrets/service1.service-secret"; + contents = '' + {{ with secret "secret/my-secret" }}{{ .Data.data | toJSON }}{{ end }} + ''; + destination = "/run/systemd-vaultd/secrets/service1.service.json"; } { - contents = ''{{ with secret "secret/blocking-secret" }}{{ .Data.data.foo }}{{ end }}''; - destination = "/run/systemd-vaultd/secrets/service2.service-secret"; + contents = '' + {{ with secret "secret/blocking-secret" }}{{ scratch.MapSet "secrets" "secret" .Data.data.foo }}{{ end }} + {{ scratch.Get "secrets" | explodeMap | toJSON }} + ''; + destination = "/run/systemd-vaultd/secrets/service2.service.json"; } ]; @@ -130,7 +135,7 @@ in { machine.wait_for_unit("service1.service") out = machine.succeed("cat /tmp/service1") print(out) - assert out == "bar" + assert out == "bar", f"{out} != bar" out = machine.succeed("systemctl list-jobs") print(out) assert "service2.service" in out, "service2 should be still blocked" diff --git a/secrets.go b/secrets.go new file mode 100644 index 0000000..3cd3f80 --- /dev/null +++ b/secrets.go @@ -0,0 +1,20 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +func parseServiceSecrets(path string) (map[string]string, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("Cannot read json file '%s': %w", path, err) + } + var data map[string]string + err = json.Unmarshal(content, &data) + if err != nil { + return nil, fmt.Errorf("Cannot parse '%s' as json file: %w", path, err) + } + return data, nil +} diff --git a/tests/random_service.py b/tests/random_service.py index 1a322a5..2a89264 100644 --- a/tests/random_service.py +++ b/tests/random_service.py @@ -2,6 +2,7 @@ import random import string +import json from dataclasses import dataclass from pathlib import Path @@ -16,10 +17,15 @@ class Service: secret_name: str secret_path: Path + def write_secret(self, val: str) -> None: + tmp = self.secret_path.with_name(self.secret_path.name + ".tmp") + tmp.write_text(json.dumps({self.secret_name: val})) + tmp.rename(self.secret_path) + def random_service(secrets_dir: Path) -> Service: service = f"test-service-{rand_word(8)}.service" secret_name = "foo" - secret = f"{service}-{secret_name}" + secret = f"{service}.json" secret_path = secrets_dir / secret return Service(service, secret_name, secret_path) diff --git a/tests/test_blocking_secret.py b/tests/test_blocking_secret.py index d5886b1..0e8d5a6 100644 --- a/tests/test_blocking_secret.py +++ b/tests/test_blocking_secret.py @@ -34,6 +34,6 @@ def test_blocking_secret(systemd_vaultd: Path, command: Command, tempdir: Path) ) time.sleep(0.1) assert proc.poll() is None, "service should block for secret" - service.secret_path.write_text("foo") + service.write_secret("foo") assert proc.stdout is not None and proc.stdout.read() == "foo" assert proc.wait() == 0 diff --git a/tests/test_socket_activation.py b/tests/test_socket_activation.py index 861b8dc..ceb2113 100644 --- a/tests/test_socket_activation.py +++ b/tests/test_socket_activation.py @@ -34,7 +34,7 @@ def test_socket_activation( time.sleep(0.1) service = random_service(secrets_dir) - service.secret_path.write_text("foo") + service.write_secret("foo") # should not block out = run( diff --git a/watcher.go b/watcher.go index 6579e66..e2b73cc 100644 --- a/watcher.go +++ b/watcher.go @@ -15,11 +15,13 @@ import ( type inotifyRequest struct { filename string + key string conn *net.UnixConn } type connection struct { fd int + key string connection *net.UnixConn } @@ -102,11 +104,11 @@ func (s *server) watch(inotifyFd int) { fdToPath[fd] = req.filename conns, ok := connsForPath[req.filename] if ok { - connsForPath[req.filename] = append(conns, connection{fd, req.conn}) + connsForPath[req.filename] = append(conns, connection{fd, req.key, req.conn}) continue } - connsForPath[req.filename] = []connection{{fd, req.conn}} + connsForPath[req.filename] = []connection{{fd, req.key, req.conn}} case fname, ok := <-fsEvents: if !ok { return @@ -117,15 +119,22 @@ func (s *server) watch(inotifyFd int) { continue } delete(connsForPath, fname) + secretMap, err := parseServiceSecrets(filepath.Join(s.SecretDir, fname)) + if err != nil { + log.Printf("Failed to process service file: %v", err) + continue + } for _, conn := range conns { defer delete(fdToPath, conn.fd) - f, err := os.Open(filepath.Join(s.SecretDir, fname)) if err == nil { - defer f.Close() - - _, err := io.Copy(conn.connection, f) + val, ok := secretMap[conn.key] + if !ok { + log.Printf("Secret map % has no value for key %s", fname, conn.key) + continue + } + _, err = io.WriteString(conn.connection, val) if err == nil { log.Printf("Served %s to %s", fname, conn.connection.RemoteAddr().String()) } else {