Merge pull request 'Add docker-credential-vault credential helper' (#9) from benvin/docker-credential-vault into main
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user