Vault and OpenBao client for Zig
  • Zig 87.5%
  • Nix 8.7%
  • Shell 3.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 25eeef6cfb
Use the codeberg-small runner label
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
2026-08-27 01:56:40 -05:00
.forgejo/workflows Use the codeberg-small runner label 2026-08-27 01:56:40 -05:00
completions Add bash completions for vaultctl 2026-08-27 01:44:50 -05:00
LICENSES Add vaultz, a Vault and OpenBao client for Zig 0.16 2026-08-27 01:21:59 -05:00
nix Add bash completions for vaultctl 2026-08-27 01:44:50 -05:00
src Run the tests on every push with Forgejo Actions 2026-08-27 01:54:00 -05:00
test Add vaultz, a Vault and OpenBao client for Zig 0.16 2026-08-27 01:21:59 -05:00
.gitignore Add vaultz, a Vault and OpenBao client for Zig 0.16 2026-08-27 01:21:59 -05:00
build.zig Add bash completions for vaultctl 2026-08-27 01:44:50 -05:00
build.zig.zon Add fish completions for vaultctl 2026-08-27 01:37:29 -05:00
flake.lock Add vaultz, a Vault and OpenBao client for Zig 0.16 2026-08-27 01:21:59 -05:00
flake.nix Add vaultz, a Vault and OpenBao client for Zig 0.16 2026-08-27 01:21:59 -05:00
package.nix Add fish completions for vaultctl 2026-08-27 01:37:29 -05:00
README.md Run the tests on every push with Forgejo Actions 2026-08-27 01:54:00 -05:00
REUSE.toml Add vaultz, a Vault and OpenBao client for Zig 0.16 2026-08-27 01:21:59 -05:00

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_CACERT and BAO_CAPATH. The client consults each of those when the VAULT_ one is unset, so a shell set up for bao needs no extra environment.
  • BAO_TOKEN_PATH moves the token file; the client honors it. Left unset, bao login and vault login both 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 at secret/ by default. read, write (with check-and-set), patch, list, and the version operations: delete, deleteVersions, undeleteVersions, destroyVersions, deleteMetadata. Handles the data/, metadata/, delete/, undelete/ and destroy/ 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 from database/ and aws/. read, write, list, delete.
  • vault.token — the token's own lifecycle: lookupSelf, renewSelf, revokeSelf, and Renewer, which decides when to renew and stops when a token cannot be extended any further.
  • Client.read, Client.readAs, Client.write, Client.writeAs, Client.delete and Client.request for 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.