logo

Håvards page

Securely Sending Systemd Credentials to Podman Containers

Also posted on medium

Systemd got the ability to securely pass credentials to services a while ago, as I have previously written about. Podman has long had the ability to run kubernetes pod and deployment manifests and with podman-systemd.unit it’s easy to automatically generate systemd services to run these. With these two building blocks, we have the tools to pass credentials to container applications, but the question is how to do so easily and securely.

One option would be to mount the systemd credentials directory as a volume mount, but it presents some issues:

  • Systemd sends the path to the credentials directory as an environment variable, CREDENTIALS_DIRECTORY which is not possible to reference in the pod or deployment manifest. Even though in practice this seems to be in a predictable path, it can change and thus cause our service to fail if we hardcode the path.
  • If our application does not have support for reading credentials from file, we need to either build that into the application, create a wrapper to start the application or we could use an init container to read the credentials and add to a config file that the application reads.

What would be a better approach is to map the credentials to environment variables, as reading from environment variables is the normal practise for container applications. A possible approach could be to run an ExecStartPre command to read the credentials and create a podman secret. There is one issue with that approach, and that is that by default, podman secret uses the file driver, which creates a file on disk for the secret. This defeats the purpose of using systemd credentials and so we need another approach. You can see this by running (as a regular user):

echo baz | podman secret create foo -
less .local/share/containers/storage/secrets/filedriver/secretsdata.json

As can be seen in the podman-secret-create man page, podman has three build in drivers, file, pass and shell. We can use the shell driver to create in-memory secrets that can be passed on to the application using kubernetes secrets.

We will need two scripts to implement this. The first script will be run by ExecStartPre and creates the podman secret if it is not already created. Create /usr/local/bin/podman-create-secret.sh with executable permissions and the following content:

#!/bin/bash

app="$1"

if ! podman secret ls --format '{{.Name}}' | grep -E -q "^credentials-${app}$"
then
  echo "${netns}" | podman secret create -d shell --driver-opts="list=/usr/local/bin/podman-secret.py list,lookup=/usr/local/bin/podman-secret.py lookup,delete=/usr/local/bin/podman-secret.py delete,store=/usr/local/bin/podman-secret.py store" "credentials-${app}" -
fi

We will then need the actual script that podman will call. This will be called by the podman process started by ExecStart, so it will have access to the CREDENTIALS_DIRECTORY environment variable, as well as the credentials. Create /usr/local/bin/podman-secret.py with executable permission and the following content:

#!/usr/bin/env python3

import argparse
from base64 import b64decode, b64encode
import binascii
import json
from os import environ
from pathlib import Path


def parse_json_secret(data: dict) -> dict:
    parsed_data = {}
    for k, v in data.items():
        if isinstance(v, str):
            # Test if value is already base64 encoded as required by
            # kubernetes secret, otherwise encode it
            try:
                b64decode(v)
                parsed_data[k] = v
            except binascii.Error:
                parsed_data[k] = b64encode(v.encode("utf-8")).decode("ascii")
        else:
            parsed_data[k] = b64encode(json.dumps(v).encode("utf-8")).decode("ascii")
    return parsed_data


def get_credentials_secrets():
    secrets = {}
    if "CREDENTIALS_DIRECTORY" not in environ:
        return secrets
    for file in Path(environ["CREDENTIALS_DIRECTORY"]).iterdir():
        with file.open() as f:
            if file.name.endswith(".json"):
                secrets.update(parse_json_secret(json.load(f)))
            else:
                # kubernetes secrets must have base64 encoded values
                secrets[file.name] = b64encode(f.read().encode("utf-8")).decode("ascii")
    return secrets


def lookup_secret():
    secrets = get_credentials_secrets()
    secret = {
        "apiVersion": "v1",
        "kind": "Secret",
        "type": "Opaque",
        "data": secrets,
    }
    print(json.dumps(secret))


def podman_secret():
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=["delete", "list", "lookup", "store"])
    args = parser.parse_args()

    match args.action:
        case "delete":
            return
        case "list":
            return
        case "store":
            return
        case "lookup":
            lookup_secret()


if __name__ == "__main__":
    podman_secret()

The script supports two types of secrets. If the filename of the credential ends with .json, the script will parse it as json and add it directly to the secret. Otherwise, it will add the content of the credential file with the key being the name of the file.

To test and show how to use this, let us first create two secrets (if you do not have the new run0 command which came with systemd 256 in June 2024, replace run0 with sudo):

echo -n bar | run0 systemd-creds encrypt --name=foo - /etc/test.creds
echo '{"TEST_SECRET": "very secret", "TEST_SECRET_COMPLEX": ["a", "b", "c"]}' | run0 systemd-creds encrypt --name=test.json - /etc/test2.creds

We then need a pod manifest, create /tmp/test.yaml with the following content

---
apiVersion: v1
kind: Pod
metadata:
  name: test
spec:
  containers:
    - name: test
      image: busybox
      args:
        - /bin/sh
        - -c
        - sleep infinity
      envFrom:
        - secretRef:
            name: credentials-test
      securityContext:
        readOnlyRootFilesystem: true
  restartPolicy: Never
  securityContext:
    # run as nobody instead of root
    runAsUser: 65384
    runAsGroup: 65384

And the .kube file needs to be created in /etc/containers/systemd/test.kube with the following content

[Unit]
Description=Test credentials

[Service]
LoadCredentialEncrypted=foo:/etc/test.creds
LoadCredentialEncrypted=test.json:/etc/test2.creds
ExecStartPre=/usr/local/bin/podman-create-secret.sh test

[Kube]
Yaml=/tmp/test.yaml
# It is good security practise to run in a user namespace
# This requires that containers has been added to
# /etc/subgid and /etc/subuid as described in
# man podman-kube-play
# This can be done by running:
# echo containers:2147483647:2147483648 | run0 tee -a /etc/subgid
# echo containers:2147483647:2147483648 | run0 tee -a /etc/subuid
UserNS=auto:uidmapping=65384:65384:65385,gidmapping=65384:65384:65385

You can now run systemctl daemon-reload and test.service will be created:

$ systemctl daemon-reload
$ systemctl cat test.service
# /run/systemd/generator/test.service
# Automatically generated by /usr/lib/systemd/system-generators/podman-system-generator
#
[Unit]
Wants=network-online.target
After=network-online.target
Description=Test credentials
SourcePath=/etc/containers/systemd/test.kube
RequiresMountsFor=%t/containers

[Service]
LoadCredentialEncrypted=foo:/etc/test.creds
LoadCredentialEncrypted=test.json:/etc/test2.creds
ExecStartPre=/usr/local/bin/podman-create-secret.sh test
KillMode=mixed
Environment=PODMAN_SYSTEMD_UNIT=%n
Type=notify
NotifyAccess=all
SyslogIdentifier=%N
ExecStart=/usr/bin/podman kube play --replace --service-container=true --userns auto:uidmapping=65384:65384:65385,gidmapping=65384:65384:65385 /tmp/test.yaml
ExecStopPost=/usr/bin/podman kube down /tmp/test.yaml

[X-Kube]
Yaml=/tmp/test.yaml
# It is good security practise to run in a user namespace
# This requires that containers has been added to
# /etc/subgid and /etc/subuid as described in
# man podman-kube-play
# Example:
# echo containers:2147483647:2147483648 | run0 tee -a /etc/subgid
# echo containers:2147483647:2147483648 | run0 tee -a /etc/subuid
UserNS=auto:uidmapping=65384:65384:65385,gidmapping=65384:65384:65385

We can now start the container and verify that it works:

$ systemctl start test.service
$ run0 podman exec test-test printenv
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
container=podman
TEST_SECRET=very secret
TEST_SECRET_COMPLEX=["a", "b", "c"]
foo=bar
HOME=/root

We see that the environment variables foo, TEST_SECRET and TEST_SECRET_COMPLEX has all been set to the values we gave to systemd-creds encrypt.