diff --git a/.github/workflows/nightly_build.yaml b/.github/workflows/nightly_build.yaml new file mode 100644 index 00000000..17dd7a14 --- /dev/null +++ b/.github/workflows/nightly_build.yaml @@ -0,0 +1,39 @@ +name: Nightly Build +on: + schedule: + # Random minute number to avoid GH scheduler stampede + - cron: '37 21 * * *' + workflow_dispatch: {} + +jobs: + build-and-publish-images: + runs-on: ubuntu-22.04 + + permissions: + contents: read + id-token: write + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version: 1.21.5 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Install regctl + uses: regclient/actions/regctl-installer@main + - name: Build image + run: make docker-build + - name: Log in to GHCR + uses: docker/login-action@v3.0.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push images + run: ./.github/workflows/scripts/push-images.sh nightly diff --git a/.github/workflows/scripts/load-oci-archives.sh b/.github/workflows/scripts/load-oci-archives.sh new file mode 100755 index 00000000..5c3489fd --- /dev/null +++ b/.github/workflows/scripts/load-oci-archives.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +## +## USAGE: __PROG__ +## +## "__PROG__" loads oci tarballs created with xbuild into docker. +## +## Usage example(s): +## ./__PROG__ +## PLATFORM=linux/arm64 ./__PROG__ +## +## Commands +## - ./__PROG__ loads the oci tarball into Docker. + +function usage { + grep '^##' "$0" | sed -e 's/^##//' -e "s/__PROG__/$me/" >&2 +} + +function normalize_path { + # Remove all /./ sequences. + local path=${1//\/.\//\/} + local npath + # Remove first dir/.. sequence. + npath="${path//[^\/][^\/]*\/\.\.\//}" + # Remove remaining dir/.. sequence. + while [[ $npath != "$path" ]] ; do + path=$npath + npath="${path//[^\/][^\/]*\/\.\.\//}" + done + echo "$path" +} + +me=$(basename "$0") +BASEDIR=$(dirname "$0") +ROOTDIR="$(normalize_path "$BASEDIR/../../../")" + +command -v regctl >/dev/null 2>&1 || { usage; echo -e "\n * The regctl cli is required to run this script." >&2 ; exit 1; } +command -v docker >/dev/null 2>&1 || { usage; echo -e "\n * The docker cli is required to run this script." >&2 ; exit 1; } + +# Takes the current platform architecture or plaftorm as defined externally in a platform variable. +# e.g.: +# linux/amd64 +# linux/arm64 +PLATFORM="${PLATFORM:-local}" +OCI_IMAGES=( + spiffe-helper +) + +org_name=$(echo "$GITHUB_REPOSITORY" | tr '/' "\n" | head -1 | tr -d "\n") +org_name="${org_name:-spiffe}" # default to spiffe in case ran on local +registry=ghcr.io/${org_name} + +echo "Importing ${OCI_IMAGES[*]} into docker". +for img in "${OCI_IMAGES[@]}"; do + oci_dir="ocidir://${ROOTDIR}oci/${img}" + platform_tar="${img}-${PLATFORM}-image.tar" + image_to_load="${registry}/${img}:devel" + + # regclient works with directories rather than tars, so import the OCI tar to a directory + regctl image import "$oci_dir" "${img}-image.tar" + dig="$(regctl image digest --platform "$PLATFORM" "$oci_dir")" + # export the single platform image using the digest + regctl image export "$oci_dir@${dig}" "${platform_tar}" + + docker load < "${platform_tar}" + docker image tag "localhost/oci/${img}:latest" "${image_to_load}" + docker image rm "localhost/oci/${img}:latest" +done diff --git a/.github/workflows/scripts/push-images.sh b/.github/workflows/scripts/push-images.sh new file mode 100755 index 00000000..80389a65 --- /dev/null +++ b/.github/workflows/scripts/push-images.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +## +## USAGE: __PROG__ +## +## "__PROG__" publishes images to a registry. +## +## Usage example(s): +## ./__PROG__ 1.5.2 +## ./__PROG__ v1.5.2 +## ./__PROG__ refs/tags/v1.5.2 +## +## Commands +## - ./__PROG__ pushes images to the registry using given version. + +set -e + +function usage { + grep '^##' "$0" | sed -e 's/^##//' -e "s/__PROG__/$me/" >&2 +} + +function normalize_path { + # Remove all /./ sequences. + local path=${1//\/.\//\/} + local npath + # Remove first dir/.. sequence. + npath="${path//[^\/][^\/]*\/\.\.\//}" + # Remove remaining dir/.. sequence. + while [[ $npath != "$path" ]] ; do + path=$npath + npath="${path//[^\/][^\/]*\/\.\.\//}" + done + echo "$path" +} + +me=$(basename "$0") +BASEDIR=$(dirname "$0") +ROOTDIR="$(normalize_path "$BASEDIR/../../../")" + +version="$1" +# remove the git tag prefix +# Push the images using the version tag (without the "v" prefix). +# Also strips the refs/tags part if the GITHUB_REF variable is used. +version="${version#refs/tags/v}" +version="${version#v}" + +if [ -z "${version}" ]; then + usage + echo "version not provided!" 1>&2 + exit 1 +fi + +image=spiffe-helper +org_name=$(echo "$GITHUB_REPOSITORY" | tr '/' "\n" | head -1 | tr -d "\n") +org_name="${org_name:-spiffe}" # default to spiffe in case ran outside of GitHub actions +registry=ghcr.io/${org_name} +image_to_push="${registry}/${image}:${version}" +oci_dir="ocidir://${ROOTDIR}oci/${image}" + +echo "Pushing ${image_to_push}." +regctl image import "${oci_dir}" "${image}-image.tar" +regctl image copy "${oci_dir}" "${image_to_push}" diff --git a/.gitignore b/.gitignore index 8e2305b3..e9c76f81 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ rpm/*.rpm *.swp *.swo bootstrap.crt + +# oci image builds +oci/ +*-image.tar diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..142ece63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# Build the spiffe-helper binary +ARG go_version +FROM --platform=$BUILDPLATFORM golang:${go_version}-alpine as base +WORKDIR /workspace + +# Cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +COPY go.* ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +# Copy the go source +COPY cmd/spiffe-helper/main.go cmd/spiffe-helper/main.go +COPY pkg/ pkg/ + +# xx is a helper for cross-compilation +# when bumping to a new version analyze the new version for security issues +# then use crane to lookup the digest of that version so we are immutable +# crane digest tonistiigi/xx:1.3.0 +FROM --platform=${BUILDPLATFORM} tonistiigi/xx@sha256:904fe94f236d36d65aeb5a2462f88f2c537b8360475f6342e7599194f291fb7e AS xx + +FROM --platform=${BUILDPLATFORM} base as builder +ARG TARGETPLATFORM +ARG TARGETARCH + +ENV CGO_ENABLED=0 +COPY --link --from=xx / / +RUN xx-go --wrap +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go build -o bin/spiffe-helper cmd/spiffe-helper/main.go + +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +#FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static AS spiffe-helper +WORKDIR / +COPY --link --from=builder /workspace/bin/spiffe-helper /spiffe-helper + +ENTRYPOINT ["/spiffe-helper"] +CMD [] diff --git a/Makefile b/Makefile index c2863cc9..0935fdda 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ export GO111MODULE=on DIR := ${CURDIR} +PLATFORMS ?= linux/amd64,linux/arm64 E:=@ ifeq ($(V),1) @@ -41,6 +42,15 @@ help: @echo "For verbose output set V=1" @echo " for example: $(cyan)make V=1 build$(reset)" +# Used to force some rules to run every time +.PHONY: FORCE +FORCE: ; + +# CONTAINER_TOOL defines the container tool to be used for building images. +# Be aware that the target commands are only tested with Docker which is +# scaffolded by default. However, you might want to replace it to use other +# tools. (i.e. podman) +CONTAINER_TOOL ?= docker ############################################################################ # OS/ARCH detection @@ -90,7 +100,7 @@ else endif go_path := PATH="$(go_bin_dir):$(PATH)" -golangci_lint_version = v1.51.1 +golangci_lint_version = v1.52.2 golangci_lint_dir = $(build_dir)/golangci_lint/$(golangci_lint_version) golangci_lint_bin = $(golangci_lint_dir)/golangci-lint golangci_lint_cache = $(golangci_lint_dir)/cache @@ -167,10 +177,26 @@ lint-code: $(golangci_lint_bin) | go-check # Build targets ############################################################################ -.PHONY: build test clean distclean artifact tarball rpm +.PHONY: build test clean distclean artifact tarball rpm docker-build container-builder load-images build: | go-check - go build -o spiffe-helper${exe} ./cmd/spiffe-helper + CGO_ENABLED=0 go build -o spiffe-helper${exe} ./cmd/spiffe-helper + +docker-build: $(addsuffix -image.tar, spiffe-helper) ## Build docker image with spiffe-helper. + +container-builder: ## Create a buildx node to create crossplatform images. + $(CONTAINER_TOOL) buildx create --platform $(PLATFORMS) --name container-builder --node container-builder0 --use + +spiffe-helper-image.tar: Dockerfile FORCE | container-builder + $(CONTAINER_TOOL) buildx build \ + --platform $(PLATFORMS) \ + --build-arg go_version=$(go_version) \ + --target spiffe-helper \ + -o type=oci,dest=$@ \ + . + +load-images: $(addsuffix -image.tar,$(BINARIES)) ## Load the image for your current PLATFORM into docker from the cross-platform oci tar. + ./.github/workflows/scripts/load-oci-archives.sh artifact: tarball rpm diff --git a/README.md b/README.md index 08f3dc07..92b25138 100644 --- a/README.md +++ b/README.md @@ -13,27 +13,29 @@ The SPIFFE Helper is a simple utility for fetching X.509 SVID certificates from If `-config` is not specified, the default value `helper.conf` is assumed. +The flag `-exitWhenReady` is also supported. + ## Configuration The configuration file is an [HCL](https://github.com/hashicorp/hcl) formatted file that defines the following configurations: - | Configuration | Description | Example Value | - |-----------------------------|----------------------------------------------------------------------------------------------------------------| -------------------- | - |`agent_address` | Socket address of SPIRE Agent. | `"/tmp/agent.sock"` | - |`cmd` | The path to the process to launch. | `"ghostunnel"` | - |`cmd_args` | The arguments of the process to launch. | `"server --listen localhost:8002 --target localhost:8001--keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database"` | - |`cert_dir` | Directory name to store the fetched certificates. This directory must be created previously. | `"certs"` | - |`add_intermediates_to_bundle`| Add intermediate certificates into Bundle file instead of SVID file. | `true` | - |`renew_signal` | The signal that the process to be launched expects to reload the certificates. It is not supported on Windows. | `"SIGUSR1"` | - |`svid_file_name` | File name to be used to store the X.509 SVID public certificate in PEM format. | `"svid.pem"` | - |`svid_key_file_name` | File name to be used to store the X.509 SVID private key and public certificate in PEM format. | `"svid_key.pem"` | - |`svid_bundle_file_name` | File name to be used to store the X.509 SVID Bundle in PEM format. | `"svid_bundle.pem"` | - |`jwt_audience` | JWT SVID audience. | `"your-audience"` | - |`jwt_svid_file_name` | File name to be used to store JWT SVID in Base64-encoded string. | `"jwt_svid.token"` | - |`jwt_bundle_file_name` | File name to be used to store JWT Bundle in JSON format. | `"jwt_bundle.json"` | + | Configuration | Description | Example Value | + |-------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | `agent_address` | Socket address of SPIRE Agent. | `"/tmp/agent.sock"` | + | `cmd` | The path to the process to launch. | `"ghostunnel"` | + | `cmd_args` | The arguments of the process to launch. | `"server --listen localhost:8002 --target localhost:8001--keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database"` | + | `cert_dir` | Directory name to store the fetched certificates. This directory must be created previously. | `"certs"` | + | `exit_when_ready` | Fetch x509 certificate and then exit(0) | `true` | + | `add_intermediates_to_bundle` | Add intermediate certificates into Bundle file instead of SVID file. | `true` | + | `renew_signal` | The signal that the process to be launched expects to reload the certificates. It is not supported on Windows. | `"SIGUSR1"` | + | `svid_file_name` | File name to be used to store the X.509 SVID public certificate in PEM format. | `"svid.pem"` | + | `svid_key_file_name` | File name to be used to store the X.509 SVID private key and public certificate in PEM format. | `"svid_key.pem"` | + | `svid_bundle_file_name` | File name to be used to store the X.509 SVID Bundle in PEM format. | `"svid_bundle.pem"` | + | `jwt_svids` | An array with the audience and file name to store the JWT SVIDs. File is Base64-encoded string). | `[{jwt_audience="your-audience", jwt_svid_file_name="jwt_svid.token"}]` | + | `jwt_bundle_file_name` | File name to be used to store JWT Bundle in JSON format. | `"jwt_bundle.json"` | ### Configuration example ``` -agent_address = "/tmp/agent.sock" +agent_address = "/tmp/spire-agent/public/api.sock" cmd = "ghostunnel" cmd_args = "server --listen localhost:8002 --target localhost:8001 --keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database" cert_dir = "certs" @@ -56,4 +58,4 @@ svid_bundle_file_name = "svid_bundle.pem" jwt_audience = "your-audience" jwt_svid_file_name = "jwt.token" jwt_bundle_file_name = "bundle.json" -``` \ No newline at end of file +``` diff --git a/cmd/spiffe-helper/main.go b/cmd/spiffe-helper/main.go index 0ea6eef0..0373d3d4 100644 --- a/cmd/spiffe-helper/main.go +++ b/cmd/spiffe-helper/main.go @@ -17,12 +17,13 @@ func main() { // 2. Run Sidecar's Daemon configFile := flag.String("config", "helper.conf", " Configuration file path") + exitWhenReady := flag.Bool("exitWhenReady", false, "Exit once the requested objects are retrieved") flag.Parse() log := logrus.WithField("system", "spiffe-helper") log.Infof("Using configuration file: %q\n", *configFile) - if err := startSidecar(*configFile, log); err != nil { + if err := startSidecar(*configFile, *exitWhenReady, log); err != nil { log.WithError(err).Error("Exiting due this error") os.Exit(1) } @@ -30,11 +31,11 @@ func main() { log.Infof("Exiting") } -func startSidecar(configPath string, log logrus.FieldLogger) error { +func startSidecar(configPath string, exitWhenReady bool, log logrus.FieldLogger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - spiffeSidecar, err := sidecar.New(configPath, log) + spiffeSidecar, err := sidecar.New(configPath, exitWhenReady, log) if err != nil { return fmt.Errorf("Failed to create sidecar: %w", err) } diff --git a/examples/mysql/helper.conf b/examples/mysql/helper.conf index 24e408f4..892376f9 100644 --- a/examples/mysql/helper.conf +++ b/examples/mysql/helper.conf @@ -1,5 +1,5 @@ # SPIRE agent unix socket path -agent_address = "/tmp/agent.sock" +agent_address = "/tmp/spire-agent/public/api.sock" # mysql binary path cmd = "/usr/bin/mysql" diff --git a/examples/mysql/spire-agent.conf b/examples/mysql/spire-agent.conf index 483914d2..aee11d3c 100644 --- a/examples/mysql/spire-agent.conf +++ b/examples/mysql/spire-agent.conf @@ -3,7 +3,7 @@ agent { log_level = "DEBUG" server_address = "127.0.0.1" server_port = "8081" - socket_path ="/tmp/agent.sock" + socket_path ="/tmp/spire-agent/public/api.sock" trust_bundle_path = "./conf/agent/dummy_root_ca.crt" trust_domain = "example.org" } diff --git a/examples/postgresql/helper.conf b/examples/postgresql/helper.conf index eada3db4..85df11a4 100644 --- a/examples/postgresql/helper.conf +++ b/examples/postgresql/helper.conf @@ -19,7 +19,7 @@ # # SPIRE agent unix socket path -agent_address = "/tmp/agent.sock" +agent_address = "/tmp/spire-agent/public/api.sock" # psql binary path cmd = "/usr/bin/psql" diff --git a/examples/postgresql/spire-agent.conf b/examples/postgresql/spire-agent.conf index 483914d2..aee11d3c 100644 --- a/examples/postgresql/spire-agent.conf +++ b/examples/postgresql/spire-agent.conf @@ -3,7 +3,7 @@ agent { log_level = "DEBUG" server_address = "127.0.0.1" server_port = "8081" - socket_path ="/tmp/agent.sock" + socket_path ="/tmp/spire-agent/public/api.sock" trust_bundle_path = "./conf/agent/dummy_root_ca.crt" trust_domain = "example.org" } diff --git a/go.mod b/go.mod index 2d941037..d470beb5 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/hashicorp/hcl v1.0.0 github.com/spiffe/go-spiffe/v2 v2.1.6 github.com/stretchr/testify v1.8.4 - golang.org/x/sys v0.15.0 - google.golang.org/grpc v1.60.0 + golang.org/x/sys v0.16.0 + google.golang.org/grpc v1.60.1 ) require google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 // indirect diff --git a/go.sum b/go.sum index 6eba6607..9954f9aa 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= @@ -56,8 +56,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97 h1:6GQBEOdGkX6MMTLT9V+TjtIRZCw9VPD5Z+yHY9wMgS0= google.golang.org/genproto/googleapis/rpc v0.0.0-20231002182017-d307bd883b97/go.mod h1:v7nGkzlmW8P3n/bKmWBn2WpBjpOEx8Q6gMueudAmKfY= -google.golang.org/grpc v1.60.0 h1:6FQAR0kM31P6MRdeluor2w2gPaS4SVNrD/DNTxrQ15k= -google.golang.org/grpc v1.60.0/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= +google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU= +google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= diff --git a/helper.conf b/helper.conf index 2a706de7..380c1fc5 100644 --- a/helper.conf +++ b/helper.conf @@ -1,4 +1,4 @@ -agent_address = "/tmp/agent.sock" +agent_address = "/tmp/spire-agent/public/api.sock" cmd = "" cmd_args = "" cert_dir = "certs" diff --git a/helper_envoy.conf b/helper_envoy.conf index 735b6179..ae8eafd0 100644 --- a/helper_envoy.conf +++ b/helper_envoy.conf @@ -1,4 +1,4 @@ -agent_address = "/tmp/agent.sock" +agent_address = "/tmp/spire-agent/public/api.sock" cmd = "hot-restarter.py" cmd_args = "start_envoy.sh" cert_dir = "certs" diff --git a/helper_ghostunnel.conf b/helper_ghostunnel.conf index 08cb0c63..70ffd5e7 100644 --- a/helper_ghostunnel.conf +++ b/helper_ghostunnel.conf @@ -1,4 +1,4 @@ -agent_address = "/tmp/agent.sock" +agent_address = "/tmp/spire-agent/public/api.sock" cmd = "ghostunnel" cmd_args = "server --listen localhost:8002 --target localhost:8001 --keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database" cert_dir = "certs" diff --git a/pkg/sidecar/config.go b/pkg/sidecar/config.go index e83c30bd..3a9ae44c 100644 --- a/pkg/sidecar/config.go +++ b/pkg/sidecar/config.go @@ -17,11 +17,13 @@ type Config struct { CmdArgsDeprecated string `hcl:"cmdArgs"` CertDir string `hcl:"cert_dir"` CertDirDeprecated string `hcl:"certDir"` + ExitWhenReady bool `hcl:"exit_when_ready"` // Merge intermediate certificates into Bundle file instead of SVID file, // it is useful is some scenarios like MySQL, // where this is the expected format for presented certificates and bundles AddIntermediatesToBundle bool `hcl:"add_intermediates_to_bundle"` AddIntermediatesToBundleDeprecated bool `hcl:"addIntermediatesToBundle"` + PidFileName string `hcl:"pid_file_name"` SvidFileName string `hcl:"svid_file_name"` SvidFileNameDeprecated string `hcl:"svidFileName"` SvidKeyFileName string `hcl:"svid_key_file_name"` @@ -32,9 +34,8 @@ type Config struct { RenewSignalDeprecated string `hcl:"renewSignal"` // JWT configuration - JWTAudience string `hcl:"jwt_audience"` - JWTSvidFilename string `hcl:"jwt_svid_file_name"` - JWTBundleFilename string `hcl:"jwt_bundle_file_name"` + JwtSvids []JwtConfig `hcl:"jwt_svids"` + JWTBundleFilename string `hcl:"jwt_bundle_file_name"` // TODO: is there a reason for this to be exposed? and inside of config? ReloadExternalProcess func() error @@ -42,6 +43,11 @@ type Config struct { Log logrus.FieldLogger } +type JwtConfig struct { + JWTAudience string `hcl:"jwt_audience"` + JWTSvidFilename string `hcl:"jwt_svid_file_name"` +} + // ParseConfig parses the given HCL file into a SidecarConfig struct func ParseConfig(file string) (*Config, error) { sidecarConfig := new(Config) @@ -120,21 +126,25 @@ func ValidateConfig(c *Config) error { c.RenewSignal = c.RenewSignalDeprecated } + for _, jwtConfig := range c.JwtSvids { + if jwtConfig.JWTSvidFilename == "" { + return errors.New("'jwt_file_name' is required in 'jwt_svids'") + } + if jwtConfig.JWTAudience == "" { + return errors.New("'jwt_audience' is required in 'jwt_svids'") + } + } + x509EmptyCount := countEmpty(c.SvidFileName, c.SvidBundleFileName, c.SvidKeyFileName) - jwtSVIDEmptyCount := countEmpty(c.JWTSvidFilename, c.JWTAudience) jwtBundleEmptyCount := countEmpty(c.SvidBundleFileName) - if x509EmptyCount == 3 && jwtSVIDEmptyCount == 2 && jwtBundleEmptyCount == 1 { - return errors.New("at least one of the sets ('svid_file_name', 'svid_key_file_name', 'svid_bundle_file_name'), ('jwt_file_name', 'jwt_audience'), or ('jwt_bundle_file_name') must be fully specified") + if x509EmptyCount == 3 && len(c.JwtSvids) == 0 && jwtBundleEmptyCount == 1 { + return errors.New("at least one of the sets ('svid_file_name', 'svid_key_file_name', 'svid_bundle_file_name'), 'jwt_svids', or 'jwt_bundle_file_name' must be fully specified") } if x509EmptyCount != 0 && x509EmptyCount != 3 { return errors.New("all or none of 'svid_file_name', 'svid_key_file_name', 'svid_bundle_file_name' must be specified") } - if jwtSVIDEmptyCount != 0 && jwtSVIDEmptyCount != 2 { - return errors.New("all or none of 'jwt_file_name', 'jwt_audience' must be specified") - } - return nil } diff --git a/pkg/sidecar/config_test.go b/pkg/sidecar/config_test.go index 36fad5b4..8d3f0570 100644 --- a/pkg/sidecar/config_test.go +++ b/pkg/sidecar/config_test.go @@ -14,7 +14,7 @@ func TestParseConfig(t *testing.T) { assert.NoError(t, err) - expectedAgentAddress := "/tmp/agent.sock" + expectedAgentAddress := "/tmp/spire-agent/public/api.sock" expectedCmd := "hot-restarter.py" expectedCmdArgs := "start_envoy.sh" expectedCertDir := "certs" @@ -34,9 +34,9 @@ func TestParseConfig(t *testing.T) { assert.Equal(t, expectedSvidFileName, c.SvidFileName) assert.Equal(t, expectedKeyFileName, c.SvidKeyFileName) assert.Equal(t, expectedSvidBundleFileName, c.SvidBundleFileName) - assert.Equal(t, expectedJWTSVIDFileName, c.JWTSvidFilename) + assert.Equal(t, expectedJWTSVIDFileName, c.JwtSvids[0].JWTSvidFilename) assert.Equal(t, expectedJWTBundleFileName, c.JWTBundleFilename) - assert.Equal(t, expectedJWTAudience, c.JWTAudience) + assert.Equal(t, expectedJWTAudience, c.JwtSvids[0].JWTAudience) assert.True(t, c.AddIntermediatesToBundle) } @@ -59,9 +59,11 @@ func TestValidateConfig(t *testing.T) { { name: "no error", config: &Config{ - AgentAddress: "path", - JWTAudience: "your-audience", - JWTSvidFilename: "jwt.token", + AgentAddress: "path", + JwtSvids: []JwtConfig{{ + JWTSvidFilename: "jwt.token", + JWTAudience: "your-audience", + }}, JWTBundleFilename: "bundle.json", }, }, @@ -70,7 +72,7 @@ func TestValidateConfig(t *testing.T) { config: &Config{ AgentAddress: "path", }, - expectError: "at least one of the sets ('svid_file_name', 'svid_key_file_name', 'svid_bundle_file_name'), ('jwt_file_name', 'jwt_audience'), or ('jwt_bundle_file_name') must be fully specified", + expectError: "at least one of the sets ('svid_file_name', 'svid_key_file_name', 'svid_bundle_file_name'), 'jwt_svids', or 'jwt_bundle_file_name' must be fully specified", }, { name: "missing svid config", @@ -81,12 +83,24 @@ func TestValidateConfig(t *testing.T) { expectError: "all or none of 'svid_file_name', 'svid_key_file_name', 'svid_bundle_file_name' must be specified", }, { - name: "missing jwt config", + name: "missing jwt audience", config: &Config{ - AgentAddress: "path", - JWTSvidFilename: "cert.pem", + AgentAddress: "path", + JwtSvids: []JwtConfig{{ + JWTSvidFilename: "jwt.token", + }}, + }, + expectError: "'jwt_audience' is required in 'jwt_svids'", + }, + { + name: "missing jwt path", + config: &Config{ + AgentAddress: "path", + JwtSvids: []JwtConfig{{ + JWTAudience: "my-audience", + }}, }, - expectError: "all or none of 'jwt_file_name', 'jwt_audience' must be specified", + expectError: "'jwt_file_name' is required in 'jwt_svids'", }, // Duplicated field error: { diff --git a/pkg/sidecar/sidecar.go b/pkg/sidecar/sidecar.go index a9a31a49..5aecd8e7 100644 --- a/pkg/sidecar/sidecar.go +++ b/pkg/sidecar/sidecar.go @@ -1,6 +1,7 @@ package sidecar import ( + "bytes" "context" "crypto/x509" "encoding/base64" @@ -11,6 +12,7 @@ import ( "os" "os/exec" "path" + "strconv" "strings" "sync" "sync/atomic" @@ -41,7 +43,7 @@ type Sidecar struct { } // New creates a new SPIFFE sidecar -func New(configPath string, log logrus.FieldLogger) (*Sidecar, error) { +func New(configPath string, exitWhenReady bool, log logrus.FieldLogger) (*Sidecar, error) { config, err := ParseConfig(configPath) if err != nil { return nil, fmt.Errorf("failed to parse %q: %w", configPath, err) @@ -68,6 +70,8 @@ func New(configPath string, log logrus.FieldLogger) (*Sidecar, error) { config.Log.Warn("No cmd defined to execute.") } + config.ExitWhenReady = config.ExitWhenReady || exitWhenReady + return &Sidecar{ config: config, certReadyChan: make(chan struct{}, 1), @@ -103,7 +107,7 @@ func (s *Sidecar) RunDaemon(ctx context.Context) error { }() } - if s.config.JWTSvidFilename != "" && s.config.JWTAudience != "" { + if len(s.config.JwtSvids) > 0 { jwtSource, err := workloadapi.NewJWTSource(ctx, workloadapi.WithClientOptions(s.getWorkloadAPIAdress())) if err != nil { s.config.Log.Fatalf("Error watching JWT svid updates: %v", err) @@ -111,11 +115,14 @@ func (s *Sidecar) RunDaemon(ctx context.Context) error { s.jwtSource = jwtSource defer s.jwtSource.Close() - wg.Add(1) - go func() { - defer wg.Done() - s.updateJWTSVID(ctx) - }() + for _, jwtConfig := range s.config.JwtSvids { + jwtConfig := jwtConfig + wg.Add(1) + go func() { + defer wg.Done() + s.updateJWTSVID(ctx, jwtConfig.JWTAudience, jwtConfig.JWTSvidFilename) + }() + } } wg.Wait() @@ -138,10 +145,12 @@ func (s *Sidecar) updateCertificates(svidResponse *workloadapi.X509Context) { } s.config.Log.Info("X.509 certificates updated") - if s.config.Cmd != "" { - if err := s.signalProcess(); err != nil { - s.config.Log.WithError(err).Error("Unable to signal process") - } + if err := s.signalProcess(); err != nil { + s.config.Log.WithError(err).Error("Unable to signal process") + } + + if s.config.ExitWhenReady { + os.Exit(0) } select { @@ -153,27 +162,46 @@ func (s *Sidecar) updateCertificates(svidResponse *workloadapi.X509Context) { // signalProcess sends the configured Renew signal to the process running the proxy // to reload itself so that the proxy uses the new SVID func (s *Sidecar) signalProcess() (err error) { + if s.config.PidFileName != "" { + byts, err := os.ReadFile(s.config.PidFileName) + if err != nil { + return fmt.Errorf("failed to read pid file: %s\n%w", s.config.PidFileName, err) + } + pid, err := strconv.Atoi(string(bytes.TrimSpace(byts))) + if err != nil { + return fmt.Errorf("failed to parse pid file: %s\n%w", s.config.PidFileName, err) + } + s.process, err = os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process: %d\n%w", pid, err) + } + if err := s.SignalProcess(); err != nil { + return err + } + } // TODO: is ReloadExternalProcess still used? switch s.config.ReloadExternalProcess { case nil: - if atomic.LoadInt32(&s.processRunning) == 0 { - cmdArgs, err := getCmdArgs(s.config.CmdArgs) - if err != nil { - return fmt.Errorf("error parsing cmd arguments: %w", err) - } - - cmd := exec.Command(s.config.Cmd, cmdArgs...) // #nosec - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Start() - if err != nil { - return fmt.Errorf("error executing process: %v\n%w", s.config.Cmd, err) - } - s.process = cmd.Process - go s.checkProcessExit() - } else { - if err := s.SignalProcess(); err != nil { - return err + if s.config.Cmd != "" { + if atomic.LoadInt32(&s.processRunning) == 0 { + cmdArgs, err := getCmdArgs(s.config.CmdArgs) + if err != nil { + return fmt.Errorf("error parsing cmd arguments: %w", err) + } + + cmd := exec.Command(s.config.Cmd, cmdArgs...) // #nosec + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Start() + if err != nil { + return fmt.Errorf("error executing process: %v\n%w", s.config.Cmd, err) + } + s.process = cmd.Process + go s.checkProcessExit() + } else { + if err := s.SignalProcess(); err != nil { + return err + } } } @@ -233,11 +261,7 @@ func (s *Sidecar) dumpBundles(svidResponse *workloadapi.X509Context) error { return err } - if err := writeCerts(svidBundleFile, bundles); err != nil { - return err - } - - return nil + return writeCerts(svidBundleFile, bundles) } func (s *Sidecar) writeJSON(fileName string, certs map[string]interface{}) error { @@ -247,11 +271,8 @@ func (s *Sidecar) writeJSON(fileName string, certs map[string]interface{}) error } jsonPath := path.Join(s.config.CertDir, fileName) - if err = os.WriteFile(jsonPath, file, os.ModePerm); err != nil { - return err - } - return nil + return os.WriteFile(jsonPath, file, os.ModePerm) } func (s *Sidecar) updateJWTBundle(jwkSet *jwtbundle.Set) { @@ -274,14 +295,14 @@ func (s *Sidecar) updateJWTBundle(jwkSet *jwtbundle.Set) { } } -func (s *Sidecar) fetchJWTSVID(ctx context.Context) (*jwtsvid.SVID, error) { - jwtSVID, err := s.jwtSource.FetchJWTSVID(ctx, jwtsvid.Params{Audience: s.config.JWTAudience}) +func (s *Sidecar) fetchJWTSVIDs(ctx context.Context, jwtAudience string) (*jwtsvid.SVID, error) { + jwtSVID, err := s.jwtSource.FetchJWTSVID(ctx, jwtsvid.Params{Audience: jwtAudience}) if err != nil { s.config.Log.Errorf("Unable to fetch JWT SVID: %v", err) return nil, err } - _, err = jwtsvid.ParseAndValidate(jwtSVID.Marshal(), s.jwtSource, []string{s.config.JWTAudience}) + _, err = jwtsvid.ParseAndValidate(jwtSVID.Marshal(), s.jwtSource, []string{jwtAudience}) if err != nil { s.config.Log.Errorf("Unable to parse or validate token: %v", err) return nil, err @@ -312,16 +333,16 @@ func getRefreshInterval(svid *jwtsvid.SVID) time.Duration { return time.Until(svid.Expiry)/2 + time.Second } -func (s *Sidecar) performJWTSVIDUpdate(ctx context.Context) (*jwtsvid.SVID, error) { +func (s *Sidecar) performJWTSVIDUpdate(ctx context.Context, jwtAudience string, jwtSvidFilename string) (*jwtsvid.SVID, error) { s.config.Log.Debug("Updating JWT SVID") - jwtSVID, err := s.fetchJWTSVID(ctx) + jwtSVID, err := s.fetchJWTSVIDs(ctx, jwtAudience) if err != nil { s.config.Log.Errorf("Unable to update JWT SVID: %v", err) return nil, err } - filePath := path.Join(s.config.CertDir, s.config.JWTSvidFilename) + filePath := path.Join(s.config.CertDir, jwtSvidFilename) if err = os.WriteFile(filePath, []byte(jwtSVID.Marshal()), os.ModePerm); err != nil { s.config.Log.Errorf("Unable to update JWT SVID: %v", err) return nil, err @@ -331,10 +352,10 @@ func (s *Sidecar) performJWTSVIDUpdate(ctx context.Context) (*jwtsvid.SVID, erro return jwtSVID, nil } -func (s *Sidecar) updateJWTSVID(ctx context.Context) { +func (s *Sidecar) updateJWTSVID(ctx context.Context, jwtAudience string, jwtSvidFilename string) { retryInterval := createRetryIntervalFunc() var initialInterval time.Duration - jwtSVID, err := s.performJWTSVIDUpdate(ctx) + jwtSVID, err := s.performJWTSVIDUpdate(ctx, jwtAudience, jwtSvidFilename) if err != nil { // If the first update fails, use the retry interval initialInterval = retryInterval() @@ -350,7 +371,7 @@ func (s *Sidecar) updateJWTSVID(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - jwtSVID, err = s.performJWTSVIDUpdate(ctx) + jwtSVID, err = s.performJWTSVIDUpdate(ctx, jwtAudience, jwtSvidFilename) if err == nil { retryInterval = createRetryIntervalFunc() ticker.Reset(getRefreshInterval(jwtSVID)) diff --git a/pkg/sidecar/sidecar_test.go b/pkg/sidecar/sidecar_test.go index fa9d43e9..3f4edf89 100644 --- a/pkg/sidecar/sidecar_test.go +++ b/pkg/sidecar/sidecar_test.go @@ -194,23 +194,31 @@ func TestSidecar_RunDaemon(t *testing.T) { func TestDefaultAgentAddress(t *testing.T) { log, _ := test.NewNullLogger() - spiffeSidecar, err := New("../../test/sidecar/config/helper.conf", log) + spiffeSidecar, err := New("../../test/sidecar/config/helper.conf", false, log) require.NoError(t, err) assert.Equal(t, spiffeSidecar.config.AgentAddress, "/tmp/spire-agent/public/api.sock") } + +func TestExitOnWaitFlag(t *testing.T) { + log, _ := test.NewNullLogger() + spiffeSidecar, err := New("../../test/sidecar/config/helper.conf", true, log) + require.NoError(t, err) + assert.Equal(t, spiffeSidecar.config.ExitWhenReady, true) +} + func TestEnvAgentAddress(t *testing.T) { - os.Setenv("SPIRE_AGENT_ADDRESS", "/tmp/agent.sock") + os.Setenv("SPIRE_AGENT_ADDRESS", "/tmp/spire-agent/public/api.sock") log, _ := test.NewNullLogger() - spiffeSidecar, err := New("../../test/sidecar/config/helper.conf", log) + spiffeSidecar, err := New("../../test/sidecar/config/helper.conf", false, log) require.NoError(t, err) - assert.Equal(t, spiffeSidecar.config.AgentAddress, "/tmp/agent.sock") + assert.Equal(t, spiffeSidecar.config.AgentAddress, "/tmp/spire-agent/public/api.sock") } func TestAgentAddress(t *testing.T) { // This test is used to verify that we get the agent_address of the .conf file instead of the ENV value, if we have both - os.Setenv("SPIRE_AGENT_ADDRESS", "/tmp/agent.sock") + os.Setenv("SPIRE_AGENT_ADDRESS", "/tmp/spire-agent/public/api.sock") log, _ := test.NewNullLogger() - spiffeSidecar, err := New("../../test/sidecar/configWithAddress/helper.conf", log) + spiffeSidecar, err := New("../../test/sidecar/configWithAddress/helper.conf", false, log) require.NoError(t, err) assert.Equal(t, spiffeSidecar.config.AgentAddress, "/tmp/spire-agent/public/api.sock") } diff --git a/pkg/sidecar/util_posix.go b/pkg/sidecar/util_posix.go index 15cfd5aa..50665947 100644 --- a/pkg/sidecar/util_posix.go +++ b/pkg/sidecar/util_posix.go @@ -33,6 +33,6 @@ func (s *Sidecar) SignalProcess() error { return nil } -func validateOSConfig(c *Config) error { +func validateOSConfig(*Config) error { return nil } diff --git a/test/fixture/config/helper.conf b/test/fixture/config/helper.conf index 0ab57b78..ccd57742 100644 --- a/test/fixture/config/helper.conf +++ b/test/fixture/config/helper.conf @@ -1,4 +1,4 @@ -agent_address = "/tmp/agent.sock" +agent_address = "/tmp/spire-agent/public/api.sock" cmd = "hot-restarter.py" cmd_args = "start_envoy.sh" cert_dir = "certs" @@ -6,8 +6,12 @@ renew_signal = "SIGHUP" svid_file_name = "svid.pem" svid_key_file_name = "svid_key.pem" svid_bundle_file_name = "svid_bundle.pem" -jwt_svid_file_name = "jwt_svid.token" jwt_bundle_file_name = "jwt_bundle.json" -jwt_audience = "your-audience" +jwt_svids = [ + { + jwt_svid_file_name = "jwt_svid.token" + jwt_audience = "your-audience" + } +] timeout = "10s" add_intermediates_to_bundle = true