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
- 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
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 = <<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
`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.
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

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

@ -73,12 +73,12 @@ in {
systemd.services.service1 = {
wantedBy = ["multi-user.target"];
script = ''
cat $CREDENTIALS_DIRECTORY/secret > /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"

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

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

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

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

Loading…
Cancel
Save