This is effectively a guest post from CM.com, which uses KumoMTA as its email infrastructure. Their Engineering team shared this with us as an example of how they integrated OpenBao as a KeySource backend for KumoMTA and we are excited to share it with the community. The full submission is reproduced below, mostly unchanged (edited for formatting only). Enjoy.
Credit: this write-up is based on integration and migration work done at CM.com by Adithya V Hebbar, Software Engineer, CM.com, with the OpenBao cluster itself set up and operated by Cyril Mengin and the CM.com Ops team.
OpenBao is a Linux Foundation-governed, open-source fork of HashiCorp Vault, created after Vault's 2023 license change to BSL. Because OpenBao implements the same KV v2 HTTP API as Vault, it works as a drop-in replacement for every vault_* KeySource field documented in the KumoMTA KeySource reference. No KumoMTA-side code changes are required.
We verified this in a production environment for two separate KumoMTA integration points:
kumo.dkim.rsa_sha256_signer's key fieldkumo.secrets.load call inside a smtp_server_auth_plain handlerWe stood up an OpenBao server or cluster. In this case, the deployment was OpenBao v2.4.4 via the official Helm chart, chart version 0.19.3. The cluster is shared and operated by the platform and ops team, with each internal team assigned its own namespace.
We enabled a KV v2 secrets engine at a mount path:
$ bao secrets enable -path=secret kv-v2
We wrote a policy that scopes read access to only the paths KumoMTA needs:
path "secret/data/dkim/*" {
capabilities = ["read"]
}
path "secret/data/smtp-auth/*" {
capabilities = ["read"]
}
# lets the token renew/look itself up instead of going stale
path "auth/token/*" {
capabilities = ["create", "read", "update", "list"]
}
We created an AppRole tied to that policy, then generated a token from it. The role_id and secret_id act roughly like a one-time username and password pair used to mint the token. We then made the resulting token available to the kumod process through VAULT_ADDR and VAULT_TOKEN.
We pointed KumoMTA's existing Vault-shaped configuration fields at the OpenBao endpoint exactly as if it were HashiCorp Vault. That was the full change.
Secrets redacted.
local vault_signer = kumo.dkim.rsa_sha256_signer {
key = {
vault_mount = 'secret',
vault_path = 'dkim/' .. msg:from_header().domain,
-- Same as HashiCorp Vault: if omitted, values are read from
-- $VAULT_ADDR / $VAULT_TOKEN. These must be accessible to the
-- kumod user/service.
-- vault_address = "https://vault.internal.example.com"
-- vault_token = "hvs.TOKENTOKENTOKEN" -- OpenBao issues the same token format
-- vault_key = "my_custom_key_name"
},
}
Populate it with the OpenBao CLI, which mirrors Vault syntax:
$ bao kv put -mount=secret dkim/example.org key=@example-private-dkim-key.pem
We have not specifically verified the original HashiCorp vault binary against an OpenBao endpoint. We only used bao, which is the supported and recommended CLI for OpenBao. In principle, the original Vault CLI should also work because the wire API is compatible, but treat that as unconfirmed rather than tested.
This example uses kumo.secrets.load directly inside a smtp_server_auth_plain handler:
kumo.on('smtp_server_auth_plain', function(authz, authc, password, conn_meta)
local ok, secret = pcall(kumo.secrets.load, {
vault_address = os.getenv 'VAULT_ADDR',
vault_token = os.getenv 'VAULT_TOKEN',
vault_mount = 'secret',
vault_path = 'smtp-auth/' .. authc,
vault_key = 'password', -- field name is up to you; defaults to "key"
})
if not ok then
return false
end
return secret == password
end)
This pattern keeps the AppRole-issued token alive using KumoMTA's own task and event primitives, with no external sidecar or cron required:
-- token_refresher.lua
local kumo = require 'kumo'
local http_client = kumo.http.build_client({})
local mod = {}
local function renew_token(vault_addr)
local token = os.getenv 'VAULT_TOKEN'
local base = vault_addr:sub(-1) == '/' and vault_addr or (vault_addr .. '/')
local req = http_client:post(base .. 'v1/auth/token/renew-self')
req:headers { ['X-Vault-Token'] = token }
local resp = req:send()
if not resp:status_is_success() then
error(string.format('token renew failed: %s', resp:text()))
end
end
-- registers the periodic renewal loop as a named event
function mod.register(delay)
local vault_addr = os.getenv 'VAULT_ADDR'
local delay_time = (not delay or delay <= 0) and 14400 or delay -- default 4h
kumo.on('token_refresher', function()
while true do
local ok, err = pcall(renew_token, vault_addr)
if not ok then
kumo.log_error('token_refresher: renewal failed: ', tostring(err))
else
kumo.log_info 'token refreshed successfully'
end
kumo.time.sleep(delay_time)
end
end)
end
-- kicks off the loop as a background task
function mod.start()
kumo.spawn_task { event_name = 'token_refresher', args = {} }
end
return mod
init.lualocal token_refresher = require 'token_refresher'
-- registers the handler at module load time
token_refresher.register()
kumo.on('init', function()
-- ...rest of init...
-- starts the background loop once kumod is initialized
token_refresher.start()
end)
This hits OpenBao's auth/token/renew-self endpoint directly, the same endpoint exposed by Vault, on a fixed interval using the token's own renewal capability. That is why the AppRole policy needs auth/token/* read and update access. There is no dependency on an OpenBao or Vault Agent sidecar.
data/ segment: KumoMTA's Vault client adds the data/ segment internally for the KV v2 API. Your vault_mount and vault_path values should match what you would pass to vault kv put or bao kv put, with no data/ segment included, even though your ACL policy paths do need it. For example: secret/data/dkim/*.vault_namespace field and only supports the standard HashiCorp Vault-shaped fields. There is no separate namespace header option. A practical workaround is to prefix the namespace directly into the mount path:vault_mount = 'my-namespace/secret', -- "<namespace>/<mount>"
That was sufficient in practice on a shared OpenBao cluster where each internal team had its own namespace.
role_id/secret_id to token. That keeps the token available to kumod limited to the read capabilities it actually needs, plus auth/token/* so it can renew itself through the refresher process. Scope read policies narrowly by path prefix, such as separate rules for dkim/* and smtp-auth/*.medusa export | medusa import directly from Vault to OpenBao; a short script driving the vault and bao CLIs directly; or a small custom program using an HTTP client library such as VaultSharp for tighter batching and retry control. For a small number of secrets, any of the three approaches works well, and the CLI-script route requires the least new tooling.We love user stories. If your team has done something awesome with KumoMTA, please let us know.
- - - - - - - - -
KumoMTA is the first open-source MTA designed from the ground up for the world's largest commercial senders. We are fueled by Professional Services and Sponsorship revenue.
Join the Forum | Review the Docs | Read the Blog | Grab the Code | SWAG Shop