Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbc2c53e23 | |||
| 6f6fbcc2c9 | |||
| c13cda5a77 | |||
| 554607cddf | |||
| 648fd76778 |
@@ -28,7 +28,7 @@ steps:
|
|||||||
# for the shell instead of substituting them (as pipeline vars) at parse
|
# for the shell instead of substituting them (as pipeline vars) at parse
|
||||||
# time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
|
# 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##*:}"
|
name="$${entry%%:*}"; pkg="$${entry##*:}"
|
||||||
for arch in amd64 arm64; do
|
for arch in amd64 arm64; do
|
||||||
GOOS=linux GOARCH="$$arch" \
|
GOOS=linux GOARCH="$$arch" \
|
||||||
@@ -73,6 +73,13 @@ steps:
|
|||||||
- |
|
- |
|
||||||
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
|
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
|
||||||
REPO="rpm-internal"
|
REPO="rpm-internal"
|
||||||
|
set -o pipefail
|
||||||
|
VAULT="https://vault.service.consul:8200"
|
||||||
|
AUTH=$$(mktemp)
|
||||||
|
VT=$$(jq -n --rawfile jwt /var/run/secrets/kubernetes.io/serviceaccount/token '{role:"woodpecker_artifactapi_writer",jwt:$$jwt}' \
|
||||||
|
| curl -sf -d @- "$$VAULT/v1/auth/k8s/au/syd1/login" | jq -er .auth.client_token) || exit 1
|
||||||
|
printf 'X-Vault-Token: %s\n' "$$VT" | curl -sf -H @- "$$VAULT/v1/identity/oidc/token/artifactapi-writer" \
|
||||||
|
| jq -er '"Authorization: Bearer " + .data.token' > "$$AUTH" || exit 1
|
||||||
for rpm in dist/*.rpm; do
|
for rpm in dist/*.rpm; do
|
||||||
FILE=$$(basename "$$rpm")
|
FILE=$$(basename "$$rpm")
|
||||||
# artifactapi has no HEAD route (returns 405); probe with GET against
|
# artifactapi has no HEAD route (returns 405); probe with GET against
|
||||||
@@ -85,7 +92,7 @@ steps:
|
|||||||
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
|
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
|
||||||
curl -f -X PUT \
|
curl -f -X PUT \
|
||||||
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
|
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
|
||||||
-H "Content-Type: application/x-rpm" \
|
-H "Content-Type: application/x-rpm" -H @"$$AUTH" \
|
||||||
--data-binary @"$$rpm"
|
--data-binary @"$$rpm"
|
||||||
done
|
done
|
||||||
depends_on: [package]
|
depends_on: [package]
|
||||||
@@ -134,7 +141,7 @@ steps:
|
|||||||
# root; the package step writes the RPM to dist/. Generate a checksums
|
# root; the package step writes the RPM to dist/. Generate a checksums
|
||||||
# manifest over everything we attach so downloads can be verified.
|
# manifest over everything we attach so downloads can be verified.
|
||||||
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
|
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"
|
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
|
||||||
sha256sum $$ASSETS > sha256sums.txt
|
sha256sum $$ASSETS > sha256sums.txt
|
||||||
tea releases assets create "${CI_COMMIT_TAG}" $$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)
|
shared/ # config.go, cache.go, vault.go (+ *_test.go)
|
||||||
vcm/main.go # vcm CLI (module main package)
|
vcm/main.go # vcm CLI (module main package)
|
||||||
vctx/main.go # vctx 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/vcm.md # per-command docs
|
||||||
docs/vctx.md
|
docs/vctx.md
|
||||||
go.mod # module git.unkin.net/unkin/vault-tools
|
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/.
|
# vault-tools: a Go monorepo of Vault token CLIs (vcm + vctx) sharing shared/.
|
||||||
# Each tool is a separate main package under its own subfolder.
|
# 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
|
DIST := dist
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
||||||
@@ -38,7 +39,7 @@ install:
|
|||||||
# Generate bash/zsh/fish completions for every binary into dist/completions.
|
# Generate bash/zsh/fish completions for every binary into dist/completions.
|
||||||
completions: build
|
completions: build
|
||||||
@mkdir -p $(DIST)/completions
|
@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 bash > $(DIST)/completions/$$b.bash; \
|
||||||
$(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \
|
$(DIST)/$$b completion zsh > $(DIST)/completions/_$$b; \
|
||||||
$(DIST)/$$b completion fish > $(DIST)/completions/$$b.fish; \
|
$(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. |
|
| [`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`). |
|
| [`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.
|
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
|
encrypted with a per-boot key in the kernel user keyring; after a reboot the
|
||||||
tokens show as locked, so run `vcm login` again.
|
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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
shared/ # config parsing, token cache, Vault HTTP client (shared lib + tests)
|
shared/ # config parsing, token cache, Vault HTTP client (shared lib + tests)
|
||||||
vcm/ # vcm CLI (main package)
|
vcm/ # vcm CLI (main package)
|
||||||
vctx/ # vctx CLI (main package)
|
vctx/ # vctx CLI (main package)
|
||||||
|
docker-credential-vault/ # Docker credential helper (main package)
|
||||||
docs/ # per-command documentation
|
docs/ # per-command documentation
|
||||||
packaging/nfpm.yaml# nfpm spec (envsubst-templated) for the RPM (both binaries + completions)
|
packaging/nfpm.yaml# nfpm spec (envsubst-templated) for the RPM (both binaries + completions)
|
||||||
scripts/build-rpm.sh
|
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
|
mode: 0755
|
||||||
owner: root
|
owner: root
|
||||||
group: 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).
|
# Shell completions (generated by scripts/build-rpm.sh before packaging).
|
||||||
- src: dist/completions/vcm.bash
|
- src: dist/completions/vcm.bash
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ cd "${ROOT_DIR}"
|
|||||||
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
|
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
|
||||||
VERSION="${VERSION#v}" # strip a leading v
|
VERSION="${VERSION#v}" # strip a leading v
|
||||||
PACKAGE="vault-tools"
|
PACKAGE="vault-tools"
|
||||||
BINARIES=(vcm vctx)
|
BINARIES=(vcm vctx docker-credential-vault)
|
||||||
|
COMPLETIONS=(vcm vctx)
|
||||||
DIST="dist"
|
DIST="dist"
|
||||||
|
|
||||||
for b in "${BINARIES[@]}"; do
|
for b in "${BINARIES[@]}"; do
|
||||||
@@ -26,7 +27,7 @@ done
|
|||||||
# match the shipped flags/subcommands.
|
# match the shipped flags/subcommands.
|
||||||
COMP_DIR="${DIST}/completions"
|
COMP_DIR="${DIST}/completions"
|
||||||
mkdir -p "${COMP_DIR}"
|
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 bash >"${COMP_DIR}/${b}.bash"
|
||||||
"./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}"
|
"./${DIST}/${b}" completion zsh >"${COMP_DIR}/_${b}"
|
||||||
"./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish"
|
"./${DIST}/${b}" completion fish >"${COMP_DIR}/${b}.fish"
|
||||||
@@ -37,7 +38,7 @@ export PACKAGE_VERSION="${VERSION}"
|
|||||||
export PACKAGE_RELEASE="1"
|
export PACKAGE_RELEASE="1"
|
||||||
export PACKAGE_ARCH="amd64"
|
export PACKAGE_ARCH="amd64"
|
||||||
export PACKAGE_PLATFORM="linux"
|
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_MAINTAINER="Ben Vincent <ben@unkin.net>"
|
||||||
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools"
|
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-tools"
|
||||||
export PACKAGE_LICENSE="MIT"
|
export PACKAGE_LICENSE="MIT"
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ type Context struct {
|
|||||||
OIDCListen string `yaml:"oidc_listen,omitempty"`
|
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
|
// Defaults holds file-level fallbacks applied to every context that does not
|
||||||
// set its own value.
|
// set its own value.
|
||||||
type Defaults struct {
|
type Defaults struct {
|
||||||
@@ -58,6 +68,8 @@ type Defaults struct {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Defaults Defaults `yaml:"defaults"`
|
Defaults Defaults `yaml:"defaults"`
|
||||||
Contexts map[string]Context `yaml:"contexts"`
|
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 records the file this config was loaded from (empty if none found).
|
||||||
path string
|
path string
|
||||||
|
|||||||
@@ -229,3 +229,23 @@ func Renew(rc ResolvedContext, prev *Token) (*Token, error) {
|
|||||||
}
|
}
|
||||||
return t, nil
|
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