Merge pull request 'Add docker-credential-vault credential helper' (#9) from benvin/docker-credential-vault into main
ci/woodpecker/tag/release Pipeline was successful

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-11-08 12:15:25 +11:00
10 changed files with 489 additions and 7 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ steps:
# for the shell instead of substituting them (as pipeline vars) at parse
# time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
- |
for entry in "vcm:./vcm" "vctx:./vctx"; do
for entry in "vcm:./vcm" "vctx:./vctx" "docker-credential-vault:./docker-credential-vault"; do
name="$${entry%%:*}"; pkg="$${entry##*:}"
for arch in amd64 arm64; do
GOOS=linux GOARCH="$$arch" \
@@ -141,7 +141,7 @@ steps:
# root; the package step writes the RPM to dist/. Generate a checksums
# manifest over everything we attach so downloads can be verified.
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
ASSETS="vcm-linux-amd64 vcm-linux-arm64 vctx-linux-amd64 vctx-linux-arm64"
ASSETS="vcm-linux-amd64 vcm-linux-arm64 vctx-linux-amd64 vctx-linux-arm64 docker-credential-vault-linux-amd64 docker-credential-vault-linux-arm64"
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
sha256sum $$ASSETS > sha256sums.txt
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
+1
View File
@@ -22,6 +22,7 @@ and token cache so one `~/.config/vcm/` configures everything.
shared/ # config.go, cache.go, vault.go (+ *_test.go)
vcm/main.go # vcm CLI (module main package)
vctx/main.go # vctx CLI (module main package)
docker-credential-vault/main.go # Docker credential helper (main package)
docs/vcm.md # per-command docs
docs/vctx.md
go.mod # module git.unkin.net/unkin/vault-tools
+3 -2
View File
@@ -1,6 +1,7 @@
# vault-tools: a Go monorepo of Vault token CLIs (vcm + vctx) sharing shared/.
# Each tool is a separate main package under its own subfolder.
BINARIES := vcm vctx
BINARIES := vcm vctx docker-credential-vault
COMPLETIONS := vcm vctx
DIST := dist
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
@@ -38,7 +39,7 @@ install:
# Generate bash/zsh/fish completions for every binary into dist/completions.
completions: build
@mkdir -p $(DIST)/completions
@for b in $(BINARIES); do \
@for b in $(COMPLETIONS); do \
$(DIST)/$$b completion bash > $(DIST)/completions/$$b.bash; \
$(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \
$(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \
+26
View File
@@ -9,6 +9,7 @@ knows about it.
| -------------------- | ----------------------------------------------------------------------- |
| [`vcm`](docs/vcm.md) | Log in to / renew Vault tokens per context and cache them on disk. |
| [`vctx`](docs/vctx.md) | Run the real `vault` CLI against a named context (sets `VAULT_ADDR`/`VAULT_TOKEN`/`VAULT_NAMESPACE`, then execs `vault`). |
| `docker-credential-vault` | Docker credential helper: mints a fresh Vault identity OIDC token as the registry password on every pull/push. |
See the per-command docs in [`docs/`](docs/) for full details.
@@ -59,12 +60,37 @@ plus its accessor, policies, TTL/expiry and renewable flag. Files are
encrypted with a per-boot key in the kernel user keyring; after a reboot the
tokens show as locked, so run `vcm login` again.
## Docker credential helper
`docker-credential-vault` uses a vcm context's cached token to mint
`identity/oidc/token/<role>` on demand, so no registry password is stored.
Add it to `~/.docker/config.json` and remove any `auths` entry for the registry:
```json
{"credHelpers": {"artifactapi.k8s.syd1.au.unkin.net": "vault"}}
```
`artifactapi.k8s.syd1.au.unkin.net` maps to role `artifactapi-writer`, user
`ci`, built in. Add or override registries in `~/.config/vcm/config.yaml`:
```yaml
docker:
artifactapi.k8s.syd1.au.unkin.net:
context: sydney # optional when only one context is configured
role: artifactapi-writer
username: ci
```
With no context it falls back to `VAULT_ADDR` + `VAULT_TOKEN`/`~/.vault-token`.
It never prompts: run `vcm login <context>` when the token is missing or expired.
## Layout
```
shared/ # config parsing, token cache, Vault HTTP client (shared lib + tests)
vcm/ # vcm CLI (main package)
vctx/ # vctx CLI (main package)
docker-credential-vault/ # Docker credential helper (main package)
docs/ # per-command documentation
packaging/nfpm.yaml# nfpm spec (envsubst-templated) for the RPM (both binaries + completions)
scripts/build-rpm.sh
+171
View File
@@ -0,0 +1,171 @@
// Command docker-credential-vault is a Docker credential helper that mints a
// fresh Vault identity OIDC token as the registry password on every `get`.
//
// ~/.docker/config.json: {"credHelpers": {"artifactapi.k8s.syd1.au.unkin.net": "vault"}}
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"git.unkin.net/unkin/vault-tools/shared"
)
var version = "dev"
// errNotFound is the docker-credential-helpers message docker treats as "no credentials".
const errNotFound = "credentials not found in native keychain"
const defaultUsername = "ci"
var defaultRegistries = map[string]shared.DockerRegistry{
"artifactapi.k8s.syd1.au.unkin.net": {Role: "artifactapi-writer"},
}
type credentials struct {
ServerURL string
Username string
Secret string
}
func main() {
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) != 1 {
_, _ = fmt.Fprintln(stderr, "usage: docker-credential-vault <get|store|erase|list|version>")
return 1
}
switch args[0] {
case "store", "erase":
_, _ = io.Copy(io.Discard, stdin)
return 0
case "version":
_, _ = fmt.Fprintln(stdout, version)
return 0
}
cfg, err := shared.Load()
if err != nil {
_, _ = fmt.Fprintln(stdout, err)
return 1
}
regs := registries(cfg)
switch args[0] {
case "list":
out := map[string]string{}
for host, r := range regs {
out[host] = r.Username
}
_ = json.NewEncoder(stdout).Encode(out)
return 0
case "get":
in, _ := io.ReadAll(stdin)
serverURL := strings.TrimSpace(string(in))
reg, ok := regs[hostOf(serverURL)]
if !ok {
_, _ = fmt.Fprintln(stdout, errNotFound)
return 1
}
secret, err := mint(cfg, reg)
if err != nil {
_, _ = fmt.Fprintln(stdout, err)
return 1
}
_ = json.NewEncoder(stdout).Encode(credentials{ServerURL: serverURL, Username: reg.Username, Secret: secret})
return 0
}
_, _ = fmt.Fprintf(stderr, "unknown command %q\n", args[0])
return 1
}
// registries merges the config's docker entries over the built-in defaults, field by field.
func registries(cfg *shared.Config) map[string]shared.DockerRegistry {
out := map[string]shared.DockerRegistry{}
for h, r := range defaultRegistries {
out[h] = r
}
for h, r := range cfg.Docker {
h = hostOf(h)
d := out[h]
if r.Context != "" {
d.Context = r.Context
}
if r.Role != "" {
d.Role = r.Role
}
if r.Username != "" {
d.Username = r.Username
}
out[h] = d
}
for h, r := range out {
if r.Username == "" {
r.Username = defaultUsername
}
out[h] = r
}
return out
}
// hostOf reduces a docker server URL ("https://host/v1/", "host") to its host.
func hostOf(s string) string {
if strings.Contains(s, "://") {
if u, err := url.Parse(s); err == nil {
return u.Host
}
}
h, _, _ := strings.Cut(s, "/")
return h
}
// mint picks a Vault token (the registry's vcm context, the only configured
// context, else VAULT_ADDR + VAULT_TOKEN/~/.vault-token) and mints reg.Role with it.
func mint(cfg *shared.Config, reg shared.DockerRegistry) (string, error) {
if reg.Role == "" {
return "", errors.New("docker-credential-vault: registry has no role configured")
}
ctx := reg.Context
if ctx == "" && len(cfg.Contexts) == 1 {
ctx = cfg.ContextNames()[0]
}
if ctx == "" {
addr, token := os.Getenv("VAULT_ADDR"), os.Getenv("VAULT_TOKEN")
if token == "" {
home, _ := os.UserHomeDir()
b, _ := os.ReadFile(filepath.Join(home, ".vault-token"))
token = strings.TrimSpace(string(b))
}
if addr == "" || token == "" {
return "", fmt.Errorf("docker-credential-vault: no vault context for this registry; set docker.<registry>.context in %s and run 'vcm login <context>'", shared.ConfigPath())
}
return shared.MintOIDCToken(addr, os.Getenv("VAULT_NAMESPACE"), token, reg.Role)
}
rc, err := cfg.Resolve(ctx)
if err != nil {
return "", err
}
tok, err := shared.LoadToken(ctx)
if err != nil {
return "", err
}
if !tok.ExpiresAt.IsZero() && time.Now().After(tok.ExpiresAt) {
return "", fmt.Errorf("cached token for context %q expired (run 'vcm login %s')", ctx, ctx)
}
ns := rc.Namespace
if ns == "" {
ns = tok.Namespace
}
secret, err := shared.MintOIDCToken(rc.Address, ns, tok.Token, reg.Role)
if err != nil {
return "", fmt.Errorf("%w (run 'vcm login %s' if the token is invalid)", err, ctx)
}
return secret, nil
}
+244
View File
@@ -0,0 +1,244 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.unkin.net/unkin/golib/keyring"
"git.unkin.net/unkin/vault-tools/shared"
"golang.org/x/sys/unix"
)
func TestMain(m *testing.M) {
shared.KeyringDesc = fmt.Sprintf("vault-tools-test:dcv:%d", os.Getpid())
k, err := keyring.Open(shared.KeyringDesc)
if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" {
fmt.Println("skipping: kernel keyring unavailable:", err)
os.Exit(0)
}
if err != nil {
fmt.Println(err)
os.Exit(1)
}
code := m.Run()
_ = k.Forget()
os.Exit(code)
}
// stubVault serves identity/oidc/token/<role> for token "s.good" only.
func stubVault(t *testing.T) (*httptest.Server, *[]string) {
t.Helper()
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path+" ns="+r.Header.Get("X-Vault-Namespace"))
if r.Header.Get("X-Vault-Token") != "s.good" {
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
return
}
role := strings.TrimPrefix(r.URL.Path, "/v1/identity/oidc/token/")
_, _ = fmt.Fprintf(w, `{"data":{"token":"jwt-for-%s"}}`, role)
}))
t.Cleanup(srv.Close)
return srv, &calls
}
func setup(t *testing.T, cfg string, tokens map[string]shared.Token) {
t.Helper()
cfgHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", cfgHome)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
t.Setenv("VAULT_ADDR", "")
t.Setenv("VAULT_TOKEN", "")
t.Setenv("VAULT_NAMESPACE", "")
dir := filepath.Join(cfgHome, "vcm")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
for name, tok := range tokens {
tk := tok
tk.Context = name
if err := shared.SaveToken(&tk); err != nil {
t.Fatal(err)
}
}
}
func runCmd(args []string, stdin string) (int, string, string) {
var out, errb bytes.Buffer
code := run(args, strings.NewReader(stdin), &out, &errb)
return code, out.String(), errb.String()
}
func TestGetDefaultRegistry(t *testing.T) {
srv, calls := stubVault(t)
setup(t, "contexts:\n sydney:\n address: "+srv.URL+"\n namespace: team\n",
map[string]shared.Token{"sydney": {Address: srv.URL, Token: "s.good"}})
code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net\n")
if code != 0 {
t.Fatalf("get exit %d: %s", code, out)
}
var c credentials
if err := json.Unmarshal([]byte(out), &c); err != nil {
t.Fatalf("decode %q: %v", out, err)
}
want := credentials{ServerURL: "artifactapi.k8s.syd1.au.unkin.net", Username: "ci", Secret: "jwt-for-artifactapi-writer"}
if c != want {
t.Errorf("creds = %+v, want %+v", c, want)
}
if len(*calls) != 1 || (*calls)[0] != "GET /v1/identity/oidc/token/artifactapi-writer ns=team" {
t.Errorf("vault calls = %v", *calls)
}
}
func TestGetOverrideAndSchemeURL(t *testing.T) {
srv, _ := stubVault(t)
setup(t, `contexts:
a:
address: `+srv.URL+`
b:
address: `+srv.URL+`
docker:
artifactapi.k8s.syd1.au.unkin.net:
context: b
username: pusher
reg.example.net:
context: b
role: other
`, map[string]shared.Token{"a": {Token: "s.bad"}, "b": {Token: "s.good"}})
code, out, _ := runCmd([]string{"get"}, "https://artifactapi.k8s.syd1.au.unkin.net/v1/")
if code != 0 || !strings.Contains(out, `"Username":"pusher"`) || !strings.Contains(out, `"Secret":"jwt-for-artifactapi-writer"`) {
t.Errorf("default override get = %d %s", code, out)
}
code, out, _ = runCmd([]string{"get"}, "reg.example.net")
if code != 0 || !strings.Contains(out, `"Username":"ci"`) || !strings.Contains(out, `"Secret":"jwt-for-other"`) {
t.Errorf("extra registry get = %d %s", code, out)
}
}
func TestGetUnknownRegistry(t *testing.T) {
setup(t, "", nil)
code, out, _ := runCmd([]string{"get"}, "docker.io")
if code == 0 || strings.TrimSpace(out) != errNotFound {
t.Errorf("get unknown = %d %q, want non-zero %q", code, out, errNotFound)
}
}
func TestGetNoToken(t *testing.T) {
setup(t, "contexts:\n sydney:\n address: https://v\n", nil)
code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net")
if code == 0 || !strings.Contains(out, "vcm login sydney") || strings.Contains(out, "Secret") {
t.Errorf("get without token = %d %q", code, out)
}
}
func TestGetExpiredToken(t *testing.T) {
srv, calls := stubVault(t)
setup(t, "contexts:\n sydney:\n address: "+srv.URL+"\n",
map[string]shared.Token{"sydney": {Token: "s.good", ExpiresAt: time.Now().Add(-time.Minute)}})
code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net")
if code == 0 || !strings.Contains(out, "expired") || len(*calls) != 0 {
t.Errorf("get expired = %d %q calls=%v", code, out, *calls)
}
}
func TestGetVaultDenied(t *testing.T) {
srv, _ := stubVault(t)
setup(t, "contexts:\n sydney:\n address: "+srv.URL+"\n",
map[string]shared.Token{"sydney": {Token: "s.bad"}})
code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net")
if code == 0 || !strings.Contains(out, "permission denied") || !strings.Contains(out, "vcm login sydney") {
t.Errorf("get denied = %d %q", code, out)
}
}
func TestGetEnvFallback(t *testing.T) {
srv, _ := stubVault(t)
setup(t, "", nil)
t.Setenv("VAULT_ADDR", srv.URL)
if err := os.WriteFile(filepath.Join(os.Getenv("HOME"), ".vault-token"), []byte("s.good\n"), 0o600); err != nil {
t.Fatal(err)
}
code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net")
if code != 0 || !strings.Contains(out, "jwt-for-artifactapi-writer") {
t.Errorf("~/.vault-token fallback = %d %q", code, out)
}
t.Setenv("VAULT_TOKEN", "s.bad")
if code, _, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net"); code == 0 {
t.Error("VAULT_TOKEN should take precedence over ~/.vault-token")
}
}
func TestGetNoContextNoEnv(t *testing.T) {
setup(t, "contexts:\n a:\n address: https://a\n b:\n address: https://b\n", nil)
code, out, _ := runCmd([]string{"get"}, "artifactapi.k8s.syd1.au.unkin.net")
if code == 0 || !strings.Contains(out, "docker.<registry>.context") {
t.Errorf("ambiguous context = %d %q", code, out)
}
}
func TestList(t *testing.T) {
setup(t, "docker:\n reg.example.net:\n role: r\n username: u\n", nil)
code, out, _ := runCmd([]string{"list"}, "")
var got map[string]string
if err := json.Unmarshal([]byte(out), &got); code != 0 || err != nil {
t.Fatalf("list = %d %q %v", code, out, err)
}
want := map[string]string{"artifactapi.k8s.syd1.au.unkin.net": "ci", "reg.example.net": "u"}
if len(got) != len(want) || got["artifactapi.k8s.syd1.au.unkin.net"] != "ci" || got["reg.example.net"] != "u" {
t.Errorf("list = %v, want %v", got, want)
}
}
func TestStoreEraseNoop(t *testing.T) {
setup(t, "", nil)
cache := os.Getenv("XDG_CACHE_HOME")
for _, c := range []struct{ cmd, in string }{
{"store", `{"ServerURL":"artifactapi.k8s.syd1.au.unkin.net","Username":"ci","Secret":"x"}`},
{"erase", "artifactapi.k8s.syd1.au.unkin.net"},
} {
if code, out, _ := runCmd([]string{c.cmd}, c.in); code != 0 || out != "" {
t.Errorf("%s = %d %q", c.cmd, code, out)
}
}
if ents, _ := os.ReadDir(cache); len(ents) != 0 {
t.Errorf("store/erase wrote to cache: %v", ents)
}
}
func TestBadArgs(t *testing.T) {
for _, a := range [][]string{nil, {"bogus"}, {"get", "x"}} {
if code, _, _ := runCmd(a, ""); code == 0 {
t.Errorf("run(%v) exit 0", a)
}
}
}
func TestHostOf(t *testing.T) {
for in, want := range map[string]string{
"artifactapi.k8s.syd1.au.unkin.net": "artifactapi.k8s.syd1.au.unkin.net",
"https://artifactapi.k8s.syd1.au.unkin.net/": "artifactapi.k8s.syd1.au.unkin.net",
"https://index.docker.io/v1/": "index.docker.io",
"reg:5000/": "reg:5000",
} {
if got := hostOf(in); got != want {
t.Errorf("hostOf(%q) = %q, want %q", in, got, want)
}
}
}
+6
View File
@@ -36,6 +36,12 @@ contents:
mode: 0755
owner: root
group: root
- src: dist/docker-credential-vault
dst: /usr/bin/docker-credential-vault
file_info:
mode: 0755
owner: root
group: root
# Shell completions (generated by scripts/build-rpm.sh before packaging).
- src: dist/completions/vcm.bash
+4 -3
View File
@@ -12,7 +12,8 @@ cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
PACKAGE="vault-tools"
BINARIES=(vcm vctx)
BINARIES=(vcm vctx docker-credential-vault)
COMPLETIONS=(vcm vctx)
DIST="dist"
for b in "${BINARIES[@]}"; do
@@ -26,7 +27,7 @@ done
# match the shipped flags/subcommands.
COMP_DIR="${DIST}/completions"
mkdir -p "${COMP_DIR}"
for b in "${BINARIES[@]}"; do
for b in "${COMPLETIONS[@]}"; do
"./${DIST}/${b}" completion bash >"${COMP_DIR}/${b}.bash"
"./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}"
"./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish"
@@ -37,7 +38,7 @@ export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="CLI tools for managing Vault tokens across multiple vault instances: vcm (per-context login/renew) and vctx (context-aware vault CLI wrapper)"
export PACKAGE_DESCRIPTION="CLI tools for managing Vault tokens across multiple vault instances: vcm (per-context login/renew) and vctx (context-aware vault CLI wrapper) and docker-credential-vault (Docker credential helper)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools"
export PACKAGE_LICENSE="MIT"
+12
View File
@@ -44,6 +44,16 @@ type Context struct {
OIDCListen string `yaml:"oidc_listen,omitempty"`
}
// DockerRegistry configures docker-credential-vault for one registry host.
type DockerRegistry struct {
// Context is the vcm context whose cached token mints the registry token.
Context string `yaml:"context,omitempty"`
// Role is the Vault identity OIDC role (identity/oidc/token/<role>).
Role string `yaml:"role,omitempty"`
// Username is sent to the registry alongside the minted token.
Username string `yaml:"username,omitempty"`
}
// Defaults holds file-level fallbacks applied to every context that does not
// set its own value.
type Defaults struct {
@@ -58,6 +68,8 @@ type Defaults struct {
type Config struct {
Defaults Defaults `yaml:"defaults"`
Contexts map[string]Context `yaml:"contexts"`
// Docker maps registry hosts to the identity OIDC role docker-credential-vault mints.
Docker map[string]DockerRegistry `yaml:"docker,omitempty"`
// path records the file this config was loaded from (empty if none found).
path string
+20
View File
@@ -229,3 +229,23 @@ func Renew(rc ResolvedContext, prev *Token) (*Token, error) {
}
return t, nil
}
// MintOIDCToken mints a Vault identity token for role via identity/oidc/token/<role>.
func MintOIDCToken(address, namespace, token, role string) (string, error) {
data, err := doRequest(http.MethodGet, address, "identity/oidc/token/"+role, namespace, token, nil)
if err != nil {
return "", err
}
var r struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := json.Unmarshal(data, &r); err != nil {
return "", fmt.Errorf("decoding identity token response: %w", err)
}
if r.Data.Token == "" {
return "", fmt.Errorf("identity/oidc/token/%s returned no token", role)
}
return r.Data.Token, nil
}