Tech
July 7, 2017
We're pleased to announce our first bit of open source code. It is a CLI utility that fetches secrets from the HashiCorp Vault secret store. It makes secrets available using environment variables to a process of your choosing. Vaultenv generalizes fetching secrets from Vault so you don't have to reinvent the wheel for each program in your infrastructure. We wrote it in Haskell, and you can find it on GitHub at channable/vaultenv
.
This companion post discusses:
Almost every program that needs to interface with external services or databases needs API keys or access credentials. These bits of information have collectively come to be called secrets.
You likely want to control access to secrets, manage their life cycle, and audit their use. We used to store these secrets in our git repositories. This is not ideal:
So Vault can store secrets, but how do you make these available to your programs? The default answer is to fetch them over the HTTP API. The documentation is pretty good and it works as advertised. This approach has one big architectural problem, though: Every program that requires secrets needs to know about the Vault API.
This has a couple of consequences:
main
or equivalent entrypoint, but difficult if you use a framework that initializes resources, spawns threads and runs before your code does.HashiCorp has a project, envconsul, which can fetch KV pairs from Consul and make them available through environment variables. It also supports Vault.
You can give it a list of secrets as CLI flags, and it will fetch those from Vault. In our eyes, there were a few problems. It:
exec()
3.If you're not interested in Haskell, skip straight to usage and download instructions.
We chose to write vaultenv in Haskell, because of a previous success story. It was the second project that we used Haskell for at Channable. We'd like to give another experience report.
Vaultenv was mostly written by someone on the "medium-to-advanced beginner" level. The experience was mostly positive. An advanced type system, a compiler that tells you when you have made mistakes, easy refactoring, what more could you want?
Well…
String
s for everything. It should use Maybe
more often and ship faster types for working with text.Apart from the above, there are lots of things to love about Haskell libraries. Some of my favorites:
Adding concurrency was an afterthought and a 2 line change. Want to do a bunch of HTTP requests concurrently 4? Before:
newEnvOrErrors <- mapM (requestSecret opts) secrets
And after, fetching all secrets concurrently:
import Control.Concurrent.Async
newEnvOrErrors <- Async.mapConcurrently (requestSecret opts) secrets
We measured a 3x speedup because of this two line change.
Another cool thing I learned about: Lenses. It turns out you can use these without fully understanding the type theory behind them. The code is pretty readable. Think getters and setters.
Want to get the "foo"
key out of the "data"
dictionary in the following blob of JSON?
{
"auth": null,
"data": {
"foo": "bar"
},
"lease_duration": 2764800,
"lease_id": "",
"renewable": false
}
Use a Lens!
{-# LANGUAGE OverloadedStrings #-}
import Control.Lens (preview)
import Data.Text
import qualified Data.Aeson.Lens as Lens (key, _String)
import qualified Data.ByteString.Lazy as LBS
parseResponse :: LBS.ByteString -> Maybe Text
parseResponse response =
let
getter = Lens.key "data" . Lens.key "foo" . Lens._String
in
preview getter responseBody
The OverloadedStrings
extension lets us use "data"
instead of pack "data"
-- the same goes for "foo"
. The extension automatically converts string literals to the right type based on the function you pass them to.
Implementing retries was a piece of cake thanks to the retry
package. You can specify a RetryPolicyM
, which details how often and with which delays to retry an action. Here, we use exponential backoff with jitter -- a backoff pattern that causes a low amount of contention/calls5:
import qualified Control.Retry as Retry
-- We use a limited exponential backoff with the policy
-- fullJitterBackoff that comes with the Retry package.
vaultRetryPolicy :: (MonadIO m) => Retry.RetryPolicyM m
vaultRetryPolicy =
let
maxRetries = 9 -- Try at most 10 times in total
baseDelayMicroSeconds = 40000
in Retry.fullJitterBackoff baseDelayMicroSeconds
<> Retry.limitRetries maxRetries
And then you pass this into retrying
, which also expects a predicate to determine when retries should happen and the action to retry:
Retry.retrying vaultRetryPolicy shouldRetry retryAction
APIs that separate the generic from the specific are really prevalent in Haskell. As long as your action lives in MonadIO
, you don't have to change the logic of the action itself. Lovely.
To show off Vaultenv, we need Vault itself. Download a binary from the Vault site and make sure it is in your $PATH
.
Then run the following command to start a development Vault server:
$ vault server -dev
Copy the root token that has been printed to the console, we'll need it later.
Write some secrets to the test server:
# Tell the vault client to connect over HTTP
$ export VAULT_ADDR='https://127.0.0.1:8200'
$ vault write secret/hello foo=world bar=supersecret
Let's try to load up the values of the foo
and bar
keys into a program of our choosing using vaultenv. For the purposes of this demonstration, we'll use env
-- pretend it is a program you want to run to get something done.
First, we need to create a file that specifies the secrets we want vaultenv to fetch. Let's create a file /etc/env.secrets
6 with the following content:
hello#foo
BAR=hello#bar
This tells vaultenv to fetch the contents of the foo
and bar
keys from the hello
secret. It will make each of these available through an environment variable of it's own.
The default behaviour is to infer the name of the environment variables. The contents of the foo
key will be available under HELLO_FOO
. For the bar
key, we tell vaultenv to use the BAR
environment variable. This allows interoperability with programs that you haven't written yourself and that expect environment variables with certain names.
We'll invoke vaultenv as follows:
$ /usr/bin/vaultenv \
--no-connect-tls \
--token YOUR_VAULT_TOKEN_HERE \
--secrets-file /etc/env.secrets \
-- /usr/bin/env
HELLO_FOO=world
BAR=supersecret
USER=laurens
LANGUAGE=en_US
HOME=/home/laurens
[...]
Notice that:
--no-connect-tls
to vaultenv so it can connect to the development server. It connects via HTTPS by default.--
disambiguates between options passed to vaultenv and those passed to the program. Add flags or arguments to the program like you would expect.--host
and --port
options if you want to use something different from localhost:8200
.Vault is a stable piece of our infrastructure at Channable. It has never stopped functioning on its own, although we had some trouble due to operator error8.
There were some gaps in tooling, so we had to write some glue code ourselves. This went pretty well. We fetch around 5.5 million secrets a day from Vault using vaultenv. Our biggest application needs around 50 secrets; fetching these generally takes between 300 and 600 milliseconds on our infrastructure.
There are some opportunities for future work:
requestSecret
function has type Options -> Secret -> IO (Either VaultError EnvVar)
. ↩.secrets
extension. ↩Are you interested in working at Channable? Check out our vacancy page to see if we have an open position that suits you!
Apply now