systemd-vaultd: switch to use a json file for reading files

main
Jörg Thalheim 2 years ago
parent bd1c3bccdc
commit 194336d1d0

@ -3,6 +3,11 @@
> Mostly written in a train > Mostly written in a train
- Jörg Thalheim - 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_name>.service.json`.
This project's goal is to simplify the loading of [HashiCorp This project's goal is to simplify the loading of [HashiCorp
Vault](https://www.vaultproject.io/) secrets from Vault](https://www.vaultproject.io/) secrets from
[systemd](https://systemd.io/) units. [systemd](https://systemd.io/) units.
@ -50,20 +55,32 @@ ExecStart=/usr/bin/myservice.sh
LoadCredential=foobar:/run/systemd-vaultd/sock 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 { template {
contents = "{{ with secret \"secret/my-secret\" }}{{ .Data.data.foo }}{{ end }}" # this exposes all secrets in `secret/my-secret` to the service
destination = "/run/systemd-vaultd/secrets/myservice.service-foo" contents = "#{{ with secret \"secret/my-secret\" }}{{ .Data.data | toJSON }}{{ end }}"
# an alternative is to expose only selected secrets like this:
# contents = <<EOF
# {{ with secret "secret/my-secret" }}{{ scratch.MapSet "secrets" "foobar" .Data.data.foo }}{{ end }}
# {{ scratch.Get "foobar" | explodeMap | toJSON }}
# EOF
destination = "/run/systemd-vaultd/secrets/myservice.service.json"
} }
``` ```
When `myservice` is started, systemd will open a connection to When `myservice` is started, systemd will open a connection to
`systemd-vaultd`'s socket. `systemd-vaultd` then either serve the secrets `systemd-vaultd`'s socket. `systemd-vaultd` then either serve the secrets
from `/run/systemd-vaultd/secrets/myservice.service-foo` or it waits with from `/run/systemd-vaultd/secrets/myservice.service.json` or it waits with
inotify on secret directory for vault agent to write the secret. inotify on secret directory for vault agent to write the secret.
Once the file `/run/systemd-vaultd/secrets/myservice.service.json` is present,
systemd-vaultd will parse it into a json map and lookup the keys specified in
`LoadCredential`.
## Installation ## Installation

@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -93,10 +94,10 @@ func (s *server) serveConnection(conn *net.UnixConn) {
return return
} }
log.Printf("Systemd requested secret for %s/%s", *unit, *secret) log.Printf("Systemd requested secret for %s/%s", *unit, *secret)
secretName := *unit + "-" + *secret secretName := *unit + ".json"
secretPath := filepath.Join(s.SecretDir, secretName) secretPath := filepath.Join(s.SecretDir, secretName)
f, err := os.Open(secretPath) secretMap, err := parseServiceSecrets(secretPath)
if os.IsNotExist(err) { if errors.Is(err, os.ErrNotExist) {
log.Printf("Block start until %s appears", secretPath) log.Printf("Block start until %s appears", secretPath)
shouldClose = false shouldClose = false
fd, err := connFd(conn) fd, err := connFd(conn)
@ -108,16 +109,20 @@ func (s *server) serveConnection(conn *net.UnixConn) {
log.Printf("Cannot get setup epoll for unix socket: %s", err) log.Printf("Cannot get setup epoll for unix socket: %s", err)
return return
} }
s.inotifyRequests <- inotifyRequest{filename: secretName, conn: conn} s.inotifyRequests <- inotifyRequest{filename: secretName, key: *secret, conn: conn}
return return
} else if err != nil { } else if err != nil {
log.Printf("Cannot open secret %s/%s: %v", *unit, *secret, err) log.Printf("Cannot process secret %s/%s: %v", *unit, *secret, err)
return return
} }
defer f.Close() val, ok := secretMap[*secret]
if _, err = io.Copy(conn, f); err != nil { if ok {
if _, err = io.WriteString(conn, val); err != nil {
log.Printf("Failed to send secret: %v", err) log.Printf("Failed to send secret: %v", err)
} }
} else {
log.Printf("Secret map at %s has no value for key %s", secretPath, *secret)
}
} }
func serveSecrets(s *server) error { func serveSecrets(s *server) error {

@ -73,12 +73,12 @@ in {
systemd.services.service1 = { systemd.services.service1 = {
wantedBy = ["multi-user.target"]; wantedBy = ["multi-user.target"];
script = '' script = ''
cat $CREDENTIALS_DIRECTORY/secret > /tmp/service1 cat $CREDENTIALS_DIRECTORY/foo > /tmp/service1
''; '';
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
LoadCredential = ["secret:/run/systemd-vaultd/sock"]; LoadCredential = ["foo:/run/systemd-vaultd/sock"];
}; };
}; };
@ -100,12 +100,17 @@ in {
}; };
template = [ template = [
{ {
contents = ''{{ with secret "secret/my-secret" }}{{ .Data.data.foo }}{{ end }}''; contents = ''
destination = "/run/systemd-vaultd/secrets/service1.service-secret"; {{ 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 }}''; contents = ''
destination = "/run/systemd-vaultd/secrets/service2.service-secret"; {{ 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") machine.wait_for_unit("service1.service")
out = machine.succeed("cat /tmp/service1") out = machine.succeed("cat /tmp/service1")
print(out) print(out)
assert out == "bar" assert out == "bar", f"{out} != bar"
out = machine.succeed("systemctl list-jobs") out = machine.succeed("systemctl list-jobs")
print(out) print(out)
assert "service2.service" in out, "service2 should be still blocked" assert "service2.service" in out, "service2 should be still blocked"

@ -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
}

@ -2,6 +2,7 @@
import random import random
import string import string
import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -16,10 +17,15 @@ class Service:
secret_name: str secret_name: str
secret_path: Path 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: def random_service(secrets_dir: Path) -> Service:
service = f"test-service-{rand_word(8)}.service" service = f"test-service-{rand_word(8)}.service"
secret_name = "foo" secret_name = "foo"
secret = f"{service}-{secret_name}" secret = f"{service}.json"
secret_path = secrets_dir / secret secret_path = secrets_dir / secret
return Service(service, secret_name, secret_path) return Service(service, secret_name, secret_path)

@ -34,6 +34,6 @@ def test_blocking_secret(systemd_vaultd: Path, command: Command, tempdir: Path)
) )
time.sleep(0.1) time.sleep(0.1)
assert proc.poll() is None, "service should block for secret" 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.stdout is not None and proc.stdout.read() == "foo"
assert proc.wait() == 0 assert proc.wait() == 0

@ -34,7 +34,7 @@ def test_socket_activation(
time.sleep(0.1) time.sleep(0.1)
service = random_service(secrets_dir) service = random_service(secrets_dir)
service.secret_path.write_text("foo") service.write_secret("foo")
# should not block # should not block
out = run( out = run(

@ -15,11 +15,13 @@ import (
type inotifyRequest struct { type inotifyRequest struct {
filename string filename string
key string
conn *net.UnixConn conn *net.UnixConn
} }
type connection struct { type connection struct {
fd int fd int
key string
connection *net.UnixConn connection *net.UnixConn
} }
@ -102,11 +104,11 @@ func (s *server) watch(inotifyFd int) {
fdToPath[fd] = req.filename fdToPath[fd] = req.filename
conns, ok := connsForPath[req.filename] conns, ok := connsForPath[req.filename]
if ok { if ok {
connsForPath[req.filename] = append(conns, connection{fd, req.conn}) connsForPath[req.filename] = append(conns, connection{fd, req.key, req.conn})
continue continue
} }
connsForPath[req.filename] = []connection{{fd, req.conn}} connsForPath[req.filename] = []connection{{fd, req.key, req.conn}}
case fname, ok := <-fsEvents: case fname, ok := <-fsEvents:
if !ok { if !ok {
return return
@ -117,15 +119,22 @@ func (s *server) watch(inotifyFd int) {
continue continue
} }
delete(connsForPath, fname) 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 { for _, conn := range conns {
defer delete(fdToPath, conn.fd) defer delete(fdToPath, conn.fd)
f, err := os.Open(filepath.Join(s.SecretDir, fname))
if err == nil { if err == nil {
defer f.Close() val, ok := secretMap[conn.key]
if !ok {
_, err := io.Copy(conn.connection, f) log.Printf("Secret map % has no value for key %s", fname, conn.key)
continue
}
_, err = io.WriteString(conn.connection, val)
if err == nil { if err == nil {
log.Printf("Served %s to %s", fname, conn.connection.RemoteAddr().String()) log.Printf("Served %s to %s", fname, conn.connection.RemoteAddr().String())
} else { } else {

Loading…
Cancel
Save