- Zig 87.5%
- Nix 8.7%
- Shell 3.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Codeberg's shared runners are selected by size label, not by `docker`. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Ps9jeztDKcNrzghQ3Dve3y |
||
| .forgejo/workflows | ||
| completions | ||
| LICENSES | ||
| nix | ||
| src | ||
| test | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
vaultz
A client for the HashiCorp Vault and OpenBao HTTP API, for Zig 0.16. It covers the cases that come up in practice: a program that pulls a few secrets out at start-up, and one that stores them back, using a token it already has.
It ships as a library plus vaultctl, a small command line tool that is both
useful on its own and a worked example of the API.
Vault and OpenBao
OpenBao is a fork of Vault, and the two still share the API this client speaks:
the same /v1/ paths, the same X-Vault-Token header, the same KV v1 and v2
engines, and the same JSON error envelope. Everything here works against
either, and nothing needs to be configured to choose between them.
Where OpenBao does differ is in what it calls things at the edges, and those are handled:
- It reads
BAO_ADDR,BAO_TOKEN,BAO_NAMESPACE,BAO_CACERTandBAO_CAPATH. The client consults each of those when theVAULT_one is unset, so a shell set up forbaoneeds no extra environment. BAO_TOKEN_PATHmoves the token file; the client honors it. Left unset,bao loginandvault loginboth write$HOME/.vault-token, so the same fallback serves both.
This is verified rather than assumed: nix flake check boots a NixOS VM
running a real OpenBao server, initializes and unseals it, and runs the
client's whole live-server suite against it. See
Development.
What is here
vault.Client— address, token, namespace and TLS trust store; issues requests against/v1/, and turns the server's status codes and JSON error envelope into Zig errors.vault.kv2— the versioned key/value engine, mounted atsecret/by default.read,write(with check-and-set),patch,list, and the version operations:delete,deleteVersions,undeleteVersions,destroyVersions,deleteMetadata. Handles thedata/,metadata/,delete/,undelete/anddestroy/path rewriting, so callers use the paths they see in the Vault or OpenBao UI.vault.kv1— the flat key/value engine,cubbyhole/, and other engines that answer a plain{"data": {...}}envelope, including the leased credentials fromdatabase/andaws/.read,write,list,delete.vault.token— the token's own lifecycle:lookupSelf,renewSelf,revokeSelf, andRenewer, which decides when to renew and stops when a token cannot be extended any further.Client.read,Client.readAs,Client.write,Client.writeAs,Client.deleteandClient.requestfor everything else.
Response unwrapping and the auth methods other than token are out of scope.
Using the library
const vault = @import("vault");
pub fn main(init: std.process.Init) !void {
var client: vault.Client = try .initFromEnv(init.io, init.gpa, init.environ_map, .{});
defer client.deinit();
var secret = try vault.kv2.read(&client, "myapp/config", .{});
defer secret.deinit();
const password = secret.getString("password") orelse return error.MissingField;
std.log.info("version {d}", .{secret.metadata().version});
// `password` is owned by `secret`; copy it to outlive the `deinit` above.
}
Writing takes anything std.json can stringify, so a struct literal is usually
the most convenient form:
// Replaces the whole secret, creating a new version.
var result = try vault.kv2.write(&client, "myapp/config", .{
.password = "hunter2",
.port = 5432,
}, .{});
defer result.deinit();
std.log.info("now at version {d}", .{result.version()});
// Create only if nothing is there yet; `cas` = the version you expect.
_ = vault.kv2.write(&client, "myapp/new", .{ .token = "abc" }, .{ .cas = 0 }) catch |err| switch (err) {
error.CheckAndSetMismatch => {}, // someone else got there first
else => return err,
};
// Change one field and leave the rest alone.
var patched = try vault.kv2.patch(&client, "myapp/config", .{ .port = 5433 }, .{});
defer patched.deinit();
try vault.kv2.delete(&client, "myapp/config", .{}); // reversible
try vault.kv2.deleteMetadata(&client, "myapp/old", .{}); // permanent
Keeping the token alive
A token is leased. Anything that runs longer than its token's TTL has to
renew it, or every secret read starts failing. Renewer holds the policy —
when the next renewal is due, and when to give up — and leaves the waiting to
the caller:
var renewer: vault.token.Renewer = .init(&client, .{});
while (true) {
switch (try renewer.renew()) {
.renewed => |seconds| try std.Io.sleep(io, .fromSeconds(@intCast(seconds)), .awake),
.never_expires => break, // a root token; nothing to keep alive
.not_renewable => |left| { // at its max TTL, or a batch token
std.log.warn("token expires in {d}s and cannot be renewed", .{left});
break;
},
}
}
By default it renews at the halfway point of each granted lease, so a failed
attempt still has the whole second half to retry in, and it never waits more
than an hour between checks however long the lease. Renewal does not change
the token's id, so client.token stays valid and nothing else has to be told.
For one-off use there are vault.token.lookupSelf, renewSelf and
revokeSelf directly.
initFromEnv reads the same variables as the official vault and bao CLIs:
| Variable | Meaning |
|---|---|
VAULT_ADDR, BAO_ADDR |
Base URL. Defaults to https://127.0.0.1:8200. |
VAULT_TOKEN, BAO_TOKEN |
Token; falls back to $HOME/.vault-token, which both vault login and bao login write. |
VAULT_NAMESPACE, BAO_NAMESPACE |
Namespace, on Vault Enterprise and HCP Vault. |
VAULT_CACERT, BAO_CACERT |
PEM bundle trusted in addition to the system roots. |
VAULT_CAPATH, BAO_CAPATH |
Directory of PEM files, likewise. |
BAO_TOKEN_PATH |
Path of the token file itself, replacing $HOME/.vault-token. |
The BAO_ names are OpenBao's spelling of the same settings and are consulted
only when the VAULT_ one is unset or empty, so a shell that exports both —
which is what a machine talking to both implementations ends up with — keeps
pointing where it already pointed. BAO_TOKEN_PATH is the exception with no
VAULT_ counterpart; it names the file, not its directory.
To configure it yourself instead, use Client.init, which takes the same
settings as explicit fields.
systemd credentials
For a service, prefer initFromCredentials. An environment variable holding a
token is visible in /proc/<pid>/environ to anyone who can see the process and
is inherited by every child it spawns; a systemd credential is a file in a
per-service tmpfs that only that service's user can read.
var client: vault.Client = try .initFromCredentials(io, gpa, env_map, .{});
defer client.deinit();
That reads the credentials named vault-address and vault-token, which a
unit provides with:
[Service]
LoadCredential=vault-token:/etc/myapp/vault-token
SetCredential=vault-address:https://vault.example.com:8200
A named credential that is not there is an error rather than a silent
fallback, so a unit with a typo fails at startup instead of at the first
request. Set address_credential or token_credential to null to take that
one from the options instead, and use Client.readCredential for anything
else a unit passes. Trailing newlines are trimmed, since LoadCredential=
copies its source file verbatim.
vaultctl --credentials does the same thing from a unit's ExecStart=.
Add it to a project with:
zig fetch --save git+https://codeberg.org/jcollie/vaultz.git
then in build.zig:
const vault = b.dependency("vault", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("vault", vault.module("vault"));
Installing
The flake builds and installs the CLI:
nix build .#vaultctl # result/bin/vaultctl
nix run .# -- get myapp/config
nix profile install .#vaultctl
nix flake check runs both test suites: the hermetic one as the package's
check phase, and a NixOS VM that boots a real OpenBao server and runs the
client against it. The derivation is also available without flakes:
pkgs.callPackage ./package.nix { }
The result is a statically linked ReleaseSafe binary with no runtime
dependencies. nix develop gives the toolchain for working on the source.
Shell completion
fish and bash completions are installed to the directories those shells
autoload from — share/fish/vendor_completions.d/ and
share/bash-completion/completions/. Both complete the commands and the
options that apply to each, so --cas is offered for put but not for get.
Neither falls back to local filenames, which a Vault path never is.
On NixOS, installing the package is not by itself enough for fish: it is the
fish module that puts the vendor directory on fish's search path. bash needs
nothing extra, since programs.bash.completion.enable is on by default.
programs.fish.enable = true;
environment.systemPackages = [ vaultz.packages.${system}.vaultctl ];
Completing a secret path means asking the server for it, so that is off by default — a tab press should never block on a round trip to a host that may be unreachable. Turn it on with:
set -g vaultctl_complete_paths 1 # fish
export VAULTCTL_COMPLETE_PATHS=1 # bash
It uses whatever address and token the shell already has, and assumes the
default mount, so it does not follow -m or --kv1.
Using the tool
$ vaultctl get myapp/config # every field, as JSON
{
"password": "hunter2",
"port": 5432
}
$ vaultctl get myapp/config password # one field, unquoted
hunter2
$ vaultctl list myapp # child keys, one per line
config
db/
$ PASSWORD=$(vaultctl get myapp/config password)
$ vaultctl put myapp/config user=admin password=hunter2
version 1
$ vaultctl put --cas 0 myapp/new token=abc # create, or fail if it exists
$ vaultctl patch myapp/config port=5433 # leave the other fields alone
$ vaultctl put --json myapp/config < config.json
$ vaultctl delete myapp/config
$ vaultctl lookup # the token's own lease and policies
accessor acc-123
display name my-app
type service
renewable true
ttl 7200s (expires 2026-09-01T12:00:00Z)
policies default app-read
$ vaultctl renew --increment 3600 # ask for another hour
ttl 3600s, renewable true
put replaces the whole secret — fields left out are gone from the new
version, though earlier versions still hold them. patch changes only the
fields named. Values given as k=v are stored as strings, matching
vault kv put; --json takes the payload from stdin for numbers, booleans and
nested objects.
vaultctl --help lists the rest: --mount, --version, --kv1, --address,
--namespace, destroy, revoke, and the raw and health subcommands.
It exits 0 on success, 1 on error, 2 on a bad command line, and 3 when the secret or field does not exist — so a script can tell "no such secret" apart from "the server is down".
Notes on behavior
Errors carry the server's own message. After a failed call,
client.last_error holds the text the server sent, which is usually more
specific than the error code. It is replaced by the next request, so copy it if
it needs to live longer.
Deletes differ by engine. On KV v2, delete is a soft delete that
undeleteVersions reverses; destroyVersions and deleteMetadata are
permanent. On KV v1 there is no history, so a delete is immediate and final.
Check-and-set is best-effort to detect. Both Vault and OpenBao report a
failed CAS as a plain 400 distinguished only by its message, so
error.CheckAndSetMismatch comes from matching that text — verified against
both. A server that words it differently surfaces as error.InvalidRequest
with the message in last_error.
A renewed lease is not always a longer one. The server caps renewal at the
token's maximum TTL, and past that point renew-self keeps succeeding while
the granted lease stops growing. Renewer treats a lease of zero, or one
marked non-renewable, as .not_renewable rather than looping until the token
dies; vaultctl renew exits non-zero in the same case.
404 is ambiguous by design. The server answers 404 both for a secret that
does not exist and for one the token may not know about, so
error.SecretNotFound cannot distinguish them.
Redirects are reported, not followed. Every request carries the token, and
following a Location would hand it to whatever host the response names. A
standby node's 307 surfaces as error.Redirected with the active node's
address in last_error; point the client at that address.
TLS. Certificates are verified against the system trust store. Private
clusters usually present a self-signed certificate, which needs VAULT_CACERT
or the ca_file option. There is no way to skip verification.
Tokens in memory. The client zeroes its copy of the token before freeing
it, and deinit does the same. Secret values live in the JSON arena and are
not zeroed; treat a Secret as sensitive for as long as it is alive.
Development
zig build test # unit tests, plus end-to-end tests against a fake server
zig build # builds vaultctl into zig-out/bin
zig build docs # renders the doc comments to zig-out/docs
nix build # the packaged CLI, tests included
There are two suites.
src/http_test.zig stands up a stand-in server on a loopback socket and drives
the real client against it, so the wire format — paths, headers, ?list=true,
status mapping — is checked rather than assumed. It needs no network access and
no Vault or OpenBao installation, and it is what zig build test runs.
test/integration.zig is a binary that runs the same surfaces against a real
server, because a fake only proves the client sends what it means to send, not
that a server agrees:
VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=... zig build integration
BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN=... zig build integration
It creates the mounts it needs and cleans up after itself, but it is
destructive under vaultz-integration/, so do not aim it at a production
cluster.
nix flake check runs that binary unattended inside a NixOS VM:
nix build .#checks.x86_64-linux.integration # boot OpenBao, run the suite
nix build .#openbao-vm # the same VM, to poke at by hand
./result/bin/run-openbao-vm-vm
The VM initializes and unseals OpenBao the way an operator would, rather than
using dev mode, so the sealed-server path is covered too. nix/openbao.nix
holds the server configuration and is shared by the automated check and the
interactive VM, so the two cannot drift apart.
.forgejo/workflows/test.yaml runs the hermetic suite on every push, and also
builds the CLI and the live-test binary: Zig only analyzes what is reachable,
so zig build test compiles neither main nor test/integration.zig, and a
broken CLI can sit behind a green test run. The NixOS VM check is not part of
it, since it needs a runner that can start a virtual machine.
License
MIT, except for flake.lock, which is dedicated to the public domain under
CC0-1.0 — it is generated input hashes with no creative content, so an
attribution requirement on it would be meaningless. Full texts are in
LICENSES/, and the per-file details are machine-readable: every file carries
an SPDX header, flake.lock is annotated in REUSE.toml because JSON has no
comment syntax, and reuse lint checks the lot.