This documents my journey to understand systemd CREDENTIALS.
A very short summary is that the mechanism provides credentials in a $CREDENTIALS_DIRECTORY
.
$CREDENTIALS_DIRECTORY
itself is exposed as an environment variable.
Why files?
Those who have been around a while my remember that 12factor recommended to keep config in environment variables. More modern tooling has realized this is not a good practice, because environment variables can easily leak. That is why container solutions and systemd use a special directory with files.
Small demo
Let's assume a very small test
application that simply dumps the credential cred
:
#!/bin/bash
dump() {
cat "${CREDENTIALS_DIRECTORY}/cred"
}
dump
After making it executable, we can run this with the LoadCredential
property:
chmod +x test
echo foo > secret
systemd-run --user --collect --property LoadCredential="cred:$PWD/secret" "$PWD/test"
The output can be found in the journal:
$ journalctl --user -t test
May 28 13:40:06 gijs test[988421]: foo
We have just exposed a file on the filesystem to the process. Note that systemd for system services reads the credential as root, meaning we don't need to think about file permissions. There are also options to store the credential itself encrypted, but that's not what I'm after now so that's left as an excercise for the reader.
Rotating secrets
Improving certificate handling was my primary motivation for this. You probably know those have an end date and thus need to be rotated. Big question is then whether the credential gets reread on every read or if its exposed once.
Let's enhance our test
application:
#!/bin/bash
dump() {
cat "${CREDENTIALS_DIRECTORY}/cred"
}
trap dump INT EXIT
dump
sleep 100
It now dumps the credential on start up and when interrupted or on exit.
Let's call it, rotate the secret and kill it:
echo foo > secret
systemd-run --user --collect -p LoadCredential="cred:$PWD/secret" "$PWD/test"
sleep 1 # systemd starts it asynchronously
echo bar > secret
pkill test
Now we can see two new entries in the journal:
$ journalctl --user -t test
May 28 13:40:06 gijs test[988421]: foo
May 28 13:42:51 gijs test[988704]: foo
May 28 13:42:52 gijs test[988717]: foo
Clearly the value is not reread by systemd and only on start up.
Dynamic loading
Depending on how your application is set up, it may need to be more dynamic.
Luckily systemd can also read from an AF_UNIX
socket.
Using systemd credentials to pass secrets from Hashicorp Vault to systemd services is a blog post that greatly helped me to understand this, though I think the code is more complex than it needs to be.
How do you implement this?
You still use credential:path
where credential
is the name and path
is some path that is a UNIX socket.
Like in the aforementioned blog, we'll use Python but simplify it a bit. First we'll implement a main:
import asyncio
import logging
import os
import socket
import sys
from pathlib import Path
from systemd.daemon import listen_fds
log = logging.getLogger()
async def main():
socket_fds = listen_fds()
if len(socket_fds) == 0:
log.info("No sockets passed, exiting")
sys.exit(0)
elif len(socket_fds) > 1:
log.warning("More than 1 socket passed, not supported")
sys.exit(1)
sock = socket.socket(fileno=socket_fds[0])
server = await asyncio.start_unix_server(client_connected_cb, sock=sock)
await server.serve_forever()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
except Exception as e:
log.exception(e)
sys.exit(2)
This gives a basic UNIX socket server (using asyncio.start_unix_server) that's listening on whatever socket systemd gives to it, allowing us to later use systemd's socket activation. None of this is special, but it's essential.
The interesting part is how we handle each client, which is implemented in client_connected_cb
.
async def client_connected_cb(_reader, writer):
_, _, service, credential = (
writer.get_extra_info("peername").decode("utf-8").split("/")
)
log.info("Got connection from %s, credential %s", service, credential)
try:
path = await get_credential(service, credential)
with path.open('rb') as f:
loop = asyncio.get_event_loop()
await loop.sendfile(writer.transport, f)
await writer.drain()
except FileNotFoundError:
log.error('No credential %s found for %s', credential, service)
finally:
writer.close()
await writer.wait_closed()
This is obviously based on the blog post, but with some clear differences.
The first part determines who requested the credential using asyncio.BaseTransport.get_extra_info.
Sadly, socket.getpeername isn't very helpful in understanding the resulting data.
It is actually the address it binds to, which will typically be /run/$service/$credential
.
This is an area I still need to investigate further.
The second part is what I modified from the original post.
Here we rely on a to-be-written API get_credential
to return a pathlib.Path.
That is opened and sent via sendfile.
Finally everything is cleaned up.
A very simple implementation of get_credential
is reading files from a directory:
BASE_DIRECTORY = Path(os.environ['STATE_DIRECTORY'])
async def get_credential(service, credential):
service, _ = service.split('.')
return BASE_DIRECTORY / service / credential
More complex examples can be thought of.
All of this is further implemented in my foreman-credentials repository.
Service example
Obviously none of this is of any use without a service using it. My initial target was to enhance Foreman's smart-proxy, which uses Ruby webrick.
Since that's much more complex, an example wouldn't speak for itself. Here is a minimal example:
#!/usr/bin/env ruby
require 'openssl'
require 'webrick'
require 'webrick/https'
def credential(cred)
File.read(File.join(ENV['CREDENTIALS_DIRECTORY'], cred))
end
options = {
DocumentRoot: nil,
Port: 8443,
SSLEnable: true,
SSLCertificate: OpenSSL::X509::Certificate.new(credential('server-certificate')),
SSLPrivateKey: OpenSSL::PKey.read(credential('server-key')),
}
server = WEBrick::HTTPServer.new(options)
server.mount_proc '/' do |req, res|
res.body = 'Hello, world!'
end
trap 'INT' do
server.shutdown
end
server.start
Then in a service file you can use (in combination with certbot:
[Service]
LoadCredential=server-certificate:/etc/letsencrypt/live/example.org/cert.pem
LoadCredential=server-key:/etc/letsencrypt/live/example.org/privkey.pem
Here static credentials are fine, because there's no reload implemented. Once that is implemented, then the dynamic server implementation becomes useful.