From 8ab9169c53791e543ba4be62538c99b6db56c948 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 25 Oct 2026 13:30:47 +1100 Subject: [PATCH 1/2] Log in to artifactapi with a Vault identity OIDC token --- Dockerfile | 3 +++ Makefile | 9 +++++++++ README.md | 11 +++++++++++ artifactapi-login.sh | 36 ++++++++++++++++++++++++++++++++++++ test/login_test.sh | 39 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 Makefile create mode 100755 artifactapi-login.sh create mode 100644 test/login_test.sh diff --git a/Dockerfile b/Dockerfile index a2ee409..3b600db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ FROM docker.io/woodpeckerci/plugin-docker-buildx:latest +RUN apk add --no-cache curl jq # buildkit reads this to trust artifactapi's internal-CA registry on push COPY unkin-ca.crt /etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt # fallback: system trust store (docker login / other code paths) COPY unkin-ca.crt /usr/local/share/ca-certificates/unkin-ca.crt RUN cat /usr/local/share/ca-certificates/unkin-ca.crt >> /etc/ssl/certs/ca-certificates.crt || true +COPY artifactapi-login.sh /usr/local/bin/artifactapi-login.sh +ENTRYPOINT ["/usr/local/bin/artifactapi-login.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f57dda --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +IMAGE ?= plugin-docker-buildx:test + +.PHONY: build test + +build: + docker build -t $(IMAGE) . + +test: build + docker run --rm -v $(CURDIR):/src:ro,z --entrypoint sh $(IMAGE) /src/test/login_test.sh diff --git a/README.md b/README.md index 5d29b46..81b80cd 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,14 @@ its own push therefore cannot rely on that trust yet. Once published, point Woodpecker docker-buildx steps at this image instead of the upstream one to push to the docker-internal registry. + +## artifactapi login + +When pushing to `artifactapi.k8s.syd1.au.unkin.net` with no `username`/`password` +or matching `logins` entry, the entrypoint logs in to Vault with the pod's service +account token (`auth/k8s/au/syd1/login`, role `woodpecker_artifactapi_writer`), +reads `identity/oidc/token/artifactapi-writer` and adds it as the registry password. +Override with `vault_addr`, `vault_role` and `vault_oidc_role`. Missing SA token or +a Vault error logs a warning and pushes unauthenticated. + +Run `make test` to exercise the login logic inside the built image. diff --git a/artifactapi-login.sh b/artifactapi-login.sh new file mode 100755 index 0000000..3835188 --- /dev/null +++ b/artifactapi-login.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +ARTIFACTAPI_HOST=artifactapi.k8s.syd1.au.unkin.net + +warn() { echo "artifactapi-login: $*, pushing unauthenticated" >&2; } + +artifactapi_login() { + [ "${PLUGIN_DRY_RUN:-false}" != true ] || return 0 + case "${PLUGIN_REGISTRY:-} ${PLUGIN_REPO:-}" in *"$ARTIFACTAPI_HOST"*) ;; *) return 0 ;; esac + [ -z "${PLUGIN_USERNAME:-}${PLUGIN_PASSWORD:-}" ] || return 0 + case "${PLUGIN_LOGINS:-}" in *"$ARTIFACTAPI_HOST"*) return 0 ;; esac + + sa=${SA_TOKEN_FILE:-/var/run/secrets/kubernetes.io/serviceaccount/token} + [ -r "$sa" ] || { warn "no service account token at $sa"; return 0; } + vault=${PLUGIN_VAULT_ADDR:-${VAULT_ADDR:-https://vault.service.consul:8200}} + role=${PLUGIN_VAULT_ROLE:-woodpecker_artifactapi_writer} + oidc_role=${PLUGIN_VAULT_OIDC_ROLE:-artifactapi-writer} + + client_token=$(jq -nc --arg role "$role" --arg jwt "$(cat "$sa")" '{role:$role,jwt:$jwt}' | + curl -sfS --max-time 30 -X POST --data @- "$vault/v1/auth/k8s/au/syd1/login" | + jq -er .auth.client_token) || { warn "vault login as $role failed"; return 0; } + token=$(curl -sfS --max-time 30 -H "X-Vault-Token: $client_token" "$vault/v1/identity/oidc/token/$oidc_role" | + jq -er .data.token) || { warn "reading identity/oidc/token/$oidc_role failed"; return 0; } + + PLUGIN_LOGINS=$(printf '%s' "${PLUGIN_LOGINS:-[]}" | + jq -c --arg r "$ARTIFACTAPI_HOST" --arg p "$token" '. + [{registry:$r,username:"woodpecker",password:$p}]') || + { warn "PLUGIN_LOGINS is not valid JSON"; return 0; } + export PLUGIN_LOGINS + echo "artifactapi-login: logged in to $ARTIFACTAPI_HOST via vault role $role" >&2 +} + +if [ -z "${ARTIFACTAPI_LOGIN_NOEXEC:-}" ]; then + artifactapi_login + exec /usr/local/bin/dockerd-entrypoint.sh plugin-docker-buildx "$@" +fi diff --git a/test/login_test.sh b/test/login_test.sh new file mode 100644 index 0000000..7323707 --- /dev/null +++ b/test/login_test.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -u +ARTIFACTAPI_LOGIN_NOEXEC=1 . "$(dirname "$0")/../artifactapi-login.sh" + +SA_TOKEN_FILE=$(mktemp) +echo sa-jwt > "$SA_TOKEN_FILE" +REG=artifactapi.k8s.syd1.au.unkin.net +OK_LOGIN='{"registry":"'$REG'","username":"woodpecker","password":"oidc-jwt"}' +VAULT_FAIL= + +curl() { + [ -z "$VAULT_FAIL" ] || return 22 + case "$*" in + *auth/k8s/au/syd1/login*) grep -q '"role":"woodpecker_artifactapi_writer","jwt":"sa-jwt"' && echo '{"auth":{"client_token":"s.client"}}' ;; + *"X-Vault-Token: s.client"*identity/oidc/token/artifactapi-writer) echo '{"data":{"token":"oidc-jwt"}}' ;; + *) return 22 ;; + esac +} + +fail=0 +check() { + name=$1 want=$2; shift 2 + got=$(unset PLUGIN_REGISTRY PLUGIN_REPO PLUGIN_USERNAME PLUGIN_PASSWORD PLUGIN_LOGINS PLUGIN_DRY_RUN + for kv in "$@"; do export "$kv"; done + artifactapi_login 2>/dev/null; printf %s "${PLUGIN_LOGINS:-}") + if [ "$got" = "$want" ]; then echo "ok $name"; else echo "FAIL $name: got [$got] want [$want]"; fail=1; fi +} + +check "registry setting logs in" "[$OK_LOGIN]" PLUGIN_REGISTRY=$REG +check "repo on artifactapi logs in" "[$OK_LOGIN]" PLUGIN_REPO=$REG/docker-internal/x +check "existing logins kept" '[{"registry":"r","username":"u","password":"p"},'"$OK_LOGIN]" PLUGIN_REGISTRY=$REG 'PLUGIN_LOGINS=[{"registry":"r","username":"u","password":"p"}]' +check "other registry skipped" "" PLUGIN_REPO=git.unkin.net/unkin/x +check "dry run skipped" "" PLUGIN_REGISTRY=$REG PLUGIN_DRY_RUN=true +check "explicit creds skipped" "" PLUGIN_REGISTRY=$REG PLUGIN_USERNAME=u PLUGIN_PASSWORD=p +check "explicit login skipped" '[{"registry":"'$REG'"}]' PLUGIN_REGISTRY=$REG 'PLUGIN_LOGINS=[{"registry":"'$REG'"}]' +check "missing sa token is anonymous" "" PLUGIN_REGISTRY=$REG SA_TOKEN_FILE=/nonexistent +check "vault failure is anonymous" "" PLUGIN_REGISTRY=$REG VAULT_FAIL=1 +check "bad role is anonymous" "" PLUGIN_REGISTRY=$REG PLUGIN_VAULT_ROLE=other +exit $fail From 2211a555f95b2c80aeb51dc84af0df9f2c123fc4 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 25 Oct 2026 13:35:08 +1100 Subject: [PATCH 2/2] Keep secrets out of argv and scope the explicit-creds skip to artifactapi --- Makefile | 1 + artifactapi-login.sh | 9 +++++---- test/login_test.sh | 7 ++++++- test/stub-entrypoint.sh | 2 ++ 4 files changed, 14 insertions(+), 5 deletions(-) create mode 100755 test/stub-entrypoint.sh diff --git a/Makefile b/Makefile index 7f57dda..165cdaa 100644 --- a/Makefile +++ b/Makefile @@ -7,3 +7,4 @@ build: test: build docker run --rm -v $(CURDIR):/src:ro,z --entrypoint sh $(IMAGE) /src/test/login_test.sh + test "$$(docker run --rm -v $(CURDIR)/test/stub-entrypoint.sh:/usr/local/bin/dockerd-entrypoint.sh:ro,z $(IMAGE) a 'b c')" = "plugin-docker-buildx|a|b c" diff --git a/artifactapi-login.sh b/artifactapi-login.sh index 3835188..99624bd 100755 --- a/artifactapi-login.sh +++ b/artifactapi-login.sh @@ -8,7 +8,7 @@ warn() { echo "artifactapi-login: $*, pushing unauthenticated" >&2; } artifactapi_login() { [ "${PLUGIN_DRY_RUN:-false}" != true ] || return 0 case "${PLUGIN_REGISTRY:-} ${PLUGIN_REPO:-}" in *"$ARTIFACTAPI_HOST"*) ;; *) return 0 ;; esac - [ -z "${PLUGIN_USERNAME:-}${PLUGIN_PASSWORD:-}" ] || return 0 + case "${PLUGIN_REGISTRY:-}" in *"$ARTIFACTAPI_HOST"*) [ -z "${PLUGIN_USERNAME:-}${PLUGIN_PASSWORD:-}" ] || return 0 ;; esac case "${PLUGIN_LOGINS:-}" in *"$ARTIFACTAPI_HOST"*) return 0 ;; esac sa=${SA_TOKEN_FILE:-/var/run/secrets/kubernetes.io/serviceaccount/token} @@ -17,14 +17,15 @@ artifactapi_login() { role=${PLUGIN_VAULT_ROLE:-woodpecker_artifactapi_writer} oidc_role=${PLUGIN_VAULT_OIDC_ROLE:-artifactapi-writer} - client_token=$(jq -nc --arg role "$role" --arg jwt "$(cat "$sa")" '{role:$role,jwt:$jwt}' | + client_token=$(SA_JWT=$(cat "$sa") jq -nc --arg role "$role" '{role:$role,jwt:env.SA_JWT}' | curl -sfS --max-time 30 -X POST --data @- "$vault/v1/auth/k8s/au/syd1/login" | jq -er .auth.client_token) || { warn "vault login as $role failed"; return 0; } - token=$(curl -sfS --max-time 30 -H "X-Vault-Token: $client_token" "$vault/v1/identity/oidc/token/$oidc_role" | + token=$(printf 'X-Vault-Token: %s\n' "$client_token" | + curl -sfS --max-time 30 -H @- "$vault/v1/identity/oidc/token/$oidc_role" | jq -er .data.token) || { warn "reading identity/oidc/token/$oidc_role failed"; return 0; } PLUGIN_LOGINS=$(printf '%s' "${PLUGIN_LOGINS:-[]}" | - jq -c --arg r "$ARTIFACTAPI_HOST" --arg p "$token" '. + [{registry:$r,username:"woodpecker",password:$p}]') || + OIDC_JWT=$token jq -c --arg r "$ARTIFACTAPI_HOST" '. + [{registry:$r,username:"woodpecker",password:env.OIDC_JWT}]') || { warn "PLUGIN_LOGINS is not valid JSON"; return 0; } export PLUGIN_LOGINS echo "artifactapi-login: logged in to $ARTIFACTAPI_HOST via vault role $role" >&2 diff --git a/test/login_test.sh b/test/login_test.sh index 7323707..9b5bc88 100644 --- a/test/login_test.sh +++ b/test/login_test.sh @@ -8,11 +8,15 @@ REG=artifactapi.k8s.syd1.au.unkin.net OK_LOGIN='{"registry":"'$REG'","username":"woodpecker","password":"oidc-jwt"}' VAULT_FAIL= +no_secret_argv() { case "$*" in *sa-jwt*|*s.client*|*oidc-jwt*) echo "secret in argv: $*" >&2; return 99 ;; esac; } +jq() { no_secret_argv "$@" && command jq "$@"; } + curl() { [ -z "$VAULT_FAIL" ] || return 22 + no_secret_argv "$@" || return case "$*" in *auth/k8s/au/syd1/login*) grep -q '"role":"woodpecker_artifactapi_writer","jwt":"sa-jwt"' && echo '{"auth":{"client_token":"s.client"}}' ;; - *"X-Vault-Token: s.client"*identity/oidc/token/artifactapi-writer) echo '{"data":{"token":"oidc-jwt"}}' ;; + *"-H @-"*identity/oidc/token/artifactapi-writer) grep -qx 'X-Vault-Token: s.client' && echo '{"data":{"token":"oidc-jwt"}}' ;; *) return 22 ;; esac } @@ -32,6 +36,7 @@ check "existing logins kept" '[{"registry":"r","username":"u","password":"p"},'" check "other registry skipped" "" PLUGIN_REPO=git.unkin.net/unkin/x check "dry run skipped" "" PLUGIN_REGISTRY=$REG PLUGIN_DRY_RUN=true check "explicit creds skipped" "" PLUGIN_REGISTRY=$REG PLUGIN_USERNAME=u PLUGIN_PASSWORD=p +check "creds for another registry still log in" "[$OK_LOGIN]" PLUGIN_REGISTRY=git.unkin.net PLUGIN_REPO=$REG/docker-internal/x PLUGIN_USERNAME=u PLUGIN_PASSWORD=p check "explicit login skipped" '[{"registry":"'$REG'"}]' PLUGIN_REGISTRY=$REG 'PLUGIN_LOGINS=[{"registry":"'$REG'"}]' check "missing sa token is anonymous" "" PLUGIN_REGISTRY=$REG SA_TOKEN_FILE=/nonexistent check "vault failure is anonymous" "" PLUGIN_REGISTRY=$REG VAULT_FAIL=1 diff --git a/test/stub-entrypoint.sh b/test/stub-entrypoint.sh new file mode 100755 index 0000000..2c03e33 --- /dev/null +++ b/test/stub-entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/sh +IFS="|"; echo "$*"