diff --git a/docs/README.md b/docs/README.md index 379d402bb..456548027 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,8 @@ | Name | Type | Description | |----------------------------------------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--authorized-artifact-server-spiffe-id` | strings | SPIFFE ID of an artifact server authorized to serve artifacts to helm-controller. Can be repeated. When set, the artifact fetcher uses SPIFFE mTLS. | +| `--authorized-event-server-spiffe-id` | strings | SPIFFE ID of an event server authorized to receive events from helm-controller. Can be repeated. When set, the event recorder uses SPIFFE mTLS. | | `--concurrent` | int | The number of concurrent HelmRelease reconciles. (default 4) | | `--default-kubeconfig-service-account` | string | Default service account used for kubeconfig. | | `--default-service-account` | string | Default service account used for impersonation. | @@ -40,6 +42,36 @@ | `--watch-label-selector` | string | Watch for resources with matching labels e.g. 'sharding.fluxcd.io/key=shard1'. | | `--feature-gates` | mapStringBool | A comma separated list of key=value pairs defining the state of experimental features. | +### SPIFFE mTLS + +When at least one of the repeatable flags `--authorized-artifact-server-spiffe-id` +or `--authorized-event-server-spiffe-id` is set, helm-controller acquires an +X.509-SVID from the SPIFFE Workload API. The `SPIFFE_ENDPOINT_SOCKET` environment +variable must point to the Workload API socket. + +- `--authorized-artifact-server-spiffe-id` authorizes the given SPIFFE IDs of + artifact servers from which helm-controller fetches artifacts. When set, the + artifact fetcher uses mTLS. +- `--authorized-event-server-spiffe-id` authorizes the given SPIFFE IDs of + event servers to which helm-controller sends events. When set, the event + recorder uses mTLS. + +Each traffic type is protected independently. SPIFFE mTLS is transparent to the +Flux API: the scheme of `.status.artifact.url` does not change. + +Each flag value is interpreted as follows: + +- `spiffe:///` authorizes exactly that SPIFFE ID. +- `spiffe:////` (with a trailing slash) authorizes any + SPIFFE ID with that path prefix. +- `spiffe://` (without a path) authorizes any SPIFFE ID in that + trust domain. + +Whenever `` equals the special value `self`, helm-controller +understands it as the trust domain of the controller's own X509-SVID. + +Invalid values cause helm-controller to fail at startup. + ### Feature Gates | Name | Default Value | Description | diff --git a/go.mod b/go.mod index 7e2ba10a4..8c78587f8 100644 --- a/go.mod +++ b/go.mod @@ -21,23 +21,25 @@ require ( github.com/fluxcd/pkg/apis/acl v0.12.0 github.com/fluxcd/pkg/apis/event v0.32.0 github.com/fluxcd/pkg/apis/kustomize v1.23.0 - github.com/fluxcd/pkg/apis/meta v1.36.1-0.20261020182949-c5194b17fdad - github.com/fluxcd/pkg/auth v0.63.1-0.20261020182949-c5194b17fdad - github.com/fluxcd/pkg/cache v0.17.1-0.20261020182949-c5194b17fdad + github.com/fluxcd/pkg/apis/meta v1.36.1-0.20261021122630-1ab1573eaf5b + github.com/fluxcd/pkg/auth v0.63.1-0.20261021122630-1ab1573eaf5b + github.com/fluxcd/pkg/cache v0.17.1-0.20261021122630-1ab1573eaf5b github.com/fluxcd/pkg/chartutil v1.32.0 - github.com/fluxcd/pkg/runtime v0.120.1-0.20261020182949-c5194b17fdad + github.com/fluxcd/pkg/http/fetch v0.28.1-0.20261021122630-1ab1573eaf5b + github.com/fluxcd/pkg/runtime v0.120.1-0.20261021122630-1ab1573eaf5b + github.com/fluxcd/pkg/spiffe v0.0.0-20261021122630-1ab1573eaf5b github.com/fluxcd/pkg/ssa v0.80.0 github.com/fluxcd/pkg/testserver v0.15.0 github.com/fluxcd/source-controller/api v1.9.6 github.com/go-logr/logr v1.4.4 github.com/google/cel-go v0.29.2 github.com/google/go-cmp v0.7.0 - github.com/hashicorp/go-retryablehttp v0.7.8 github.com/mitchellh/copystructure v1.2.0 github.com/onsi/gomega v1.43.0 github.com/opencontainers/go-digest v1.0.1-0.20231025023718-d50d2fec9c98 github.com/opencontainers/go-digest/blake3 v0.0.0-20250116041648-1e56c6daea3b github.com/spf13/pflag v1.0.10 + github.com/spiffe/go-spiffe/v2 v2.9.0 github.com/wI2L/jsondiff v0.7.1 go.uber.org/zap v1.28.0 golang.org/x/text v0.43.0 @@ -73,6 +75,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect @@ -111,11 +114,13 @@ require ( github.com/extism/go-sdk v1.7.1 // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/fluxcd/pkg/apis/crypto v0.4.1-0.20261020182949-c5194b17fdad // indirect + github.com/fluxcd/pkg/apis/crypto v0.4.1-0.20261021122630-1ab1573eaf5b // indirect + github.com/fluxcd/pkg/tar v1.3.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.5 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect @@ -144,12 +149,13 @@ require ( github.com/googleapis/gax-go/v2 v2.24.0 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -181,7 +187,6 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cobra v1.10.2 // indirect - github.com/spiffe/go-spiffe/v2 v2.9.0 // indirect github.com/stretchr/testify v1.12.1 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.12.0 // indirect @@ -191,7 +196,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect - github.com/zeebo/blake3 v0.2.3 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect go.opentelemetry.io/otel v1.46.0 // indirect diff --git a/go.sum b/go.sum index 210fbdcaf..ed8535636 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -150,24 +152,30 @@ github.com/fluxcd/cli-utils v1.4.0 h1:zlTiCWcVDQvvwZXILYeu5rpbnGt1sS+w32y3+rkOpi github.com/fluxcd/cli-utils v1.4.0/go.mod h1:OupUOj2boEKTbiguMbGMz5Z/d59JUcb1c7pPESe8Pjw= github.com/fluxcd/pkg/apis/acl v0.12.0 h1:UWpZDqKxpKDW38wdXIMHV8Uv3POPu2vUrra0JH1frH0= github.com/fluxcd/pkg/apis/acl v0.12.0/go.mod h1:PJrGIu5Bw/6/siO95uhS+pi5oKSU+gVJqHqsPHb8Kok= -github.com/fluxcd/pkg/apis/crypto v0.4.1-0.20261020182949-c5194b17fdad h1:SAzeZibDaqyyq7CaccwDEySCcsOMGGuHmEN28LcRPvM= -github.com/fluxcd/pkg/apis/crypto v0.4.1-0.20261020182949-c5194b17fdad/go.mod h1:bEwkDgBjLXMVJ2BwmVbbj32PkirmNSicabgl2Jvw7Dk= +github.com/fluxcd/pkg/apis/crypto v0.4.1-0.20261021122630-1ab1573eaf5b h1:/n7p1+Yyu88dVjg3ezqCM0p96VUwMxtjgSb6cELFiQg= +github.com/fluxcd/pkg/apis/crypto v0.4.1-0.20261021122630-1ab1573eaf5b/go.mod h1:bEwkDgBjLXMVJ2BwmVbbj32PkirmNSicabgl2Jvw7Dk= github.com/fluxcd/pkg/apis/event v0.32.0 h1:3zyjdUspwQN+EQJPlUXwCvCMbeqtB+azlNmeenAcbuE= github.com/fluxcd/pkg/apis/event v0.32.0/go.mod h1:ALma8dTqo+NbI+z4BawSzt0pAPjetCSNNUSSS/9p0VU= github.com/fluxcd/pkg/apis/kustomize v1.23.0 h1:tT/sUURyh6ixQG4+Y/ZoUu+hh5/DQLRraWBgy42ubqQ= github.com/fluxcd/pkg/apis/kustomize v1.23.0/go.mod h1:Jm/LmEsqJIldhhTmhkORAq8JKVZK3ZV2Q88rntjGxEI= -github.com/fluxcd/pkg/apis/meta v1.36.1-0.20261020182949-c5194b17fdad h1:p/W9d+gf9FuNNmiwUZJT7ReGzTzwuYLUago5szqkq5I= -github.com/fluxcd/pkg/apis/meta v1.36.1-0.20261020182949-c5194b17fdad/go.mod h1:1MLvK2Xu3UdTKBzg+Q1Oj7H9PHeiKsUB1UW3uwxAwiM= -github.com/fluxcd/pkg/auth v0.63.1-0.20261020182949-c5194b17fdad h1:lUjY+hEXSYG8RdJhuNYl5mjkrnLpwuG/+A+iSybCRlE= -github.com/fluxcd/pkg/auth v0.63.1-0.20261020182949-c5194b17fdad/go.mod h1:gWbB4MzuHWalVyI0o4lE84aINidor9+S63ithckm+94= -github.com/fluxcd/pkg/cache v0.17.1-0.20261020182949-c5194b17fdad h1:Lf7S9vl776ZW7SDxdQtZcEu1vjWroZS86ePd8gost4M= -github.com/fluxcd/pkg/cache v0.17.1-0.20261020182949-c5194b17fdad/go.mod h1:OW3fncHhPnPZFZav5FdEa//cyMHSndI0JS+IM69LXOU= +github.com/fluxcd/pkg/apis/meta v1.36.1-0.20261021122630-1ab1573eaf5b h1:g/s+m/jU7cPJ+8IIIXbDD19G7TQAKbiUvqTt5GuywlM= +github.com/fluxcd/pkg/apis/meta v1.36.1-0.20261021122630-1ab1573eaf5b/go.mod h1:1MLvK2Xu3UdTKBzg+Q1Oj7H9PHeiKsUB1UW3uwxAwiM= +github.com/fluxcd/pkg/auth v0.63.1-0.20261021122630-1ab1573eaf5b h1:uqRL6xKRFwUi7AXrPj7BSRAqQYz7rxNMNjSK2W6YqB0= +github.com/fluxcd/pkg/auth v0.63.1-0.20261021122630-1ab1573eaf5b/go.mod h1:gWbB4MzuHWalVyI0o4lE84aINidor9+S63ithckm+94= +github.com/fluxcd/pkg/cache v0.17.1-0.20261021122630-1ab1573eaf5b h1:wi9U1NDlqYmbVUMntuKU71vOqnSFSslDEMbbnBnzJDs= +github.com/fluxcd/pkg/cache v0.17.1-0.20261021122630-1ab1573eaf5b/go.mod h1:OW3fncHhPnPZFZav5FdEa//cyMHSndI0JS+IM69LXOU= github.com/fluxcd/pkg/chartutil v1.32.0 h1:rqBhgiGsrS4am/jsCfKYlQvPQ+jATug10BtO95UHdq0= github.com/fluxcd/pkg/chartutil v1.32.0/go.mod h1:pBqCvJVDLpKKnU+k9A+TnceZn+DnnivfVGhzNzHJXZU= -github.com/fluxcd/pkg/runtime v0.120.1-0.20261020182949-c5194b17fdad h1:AcQzxznBRZyRthcVgFjPV49uLfu7eDOoHO1B5WyRsO8= -github.com/fluxcd/pkg/runtime v0.120.1-0.20261020182949-c5194b17fdad/go.mod h1:Cw0CQ/tVKS9lgJoyKVouZr1qVnoJcYnSHw3QDzIOOjA= +github.com/fluxcd/pkg/http/fetch v0.28.1-0.20261021122630-1ab1573eaf5b h1:q+StvNk04P/dx7IMhw4oKmcfU870OpTIkaZMxgcn5u8= +github.com/fluxcd/pkg/http/fetch v0.28.1-0.20261021122630-1ab1573eaf5b/go.mod h1:SOq8vrzru2zipskTEguXzKHabn2ASyXESQ/DY93FafY= +github.com/fluxcd/pkg/runtime v0.120.1-0.20261021122630-1ab1573eaf5b h1:/S4RzBts7XoNrxXM0hUjmVzZ+fxrXcZ6VyHJfaiWIII= +github.com/fluxcd/pkg/runtime v0.120.1-0.20261021122630-1ab1573eaf5b/go.mod h1:Cw0CQ/tVKS9lgJoyKVouZr1qVnoJcYnSHw3QDzIOOjA= +github.com/fluxcd/pkg/spiffe v0.0.0-20261021122630-1ab1573eaf5b h1:I7Z3cEgoM+EqYpLkr0xI3a1z+Qh1WbZKDCOd8EeGYJg= +github.com/fluxcd/pkg/spiffe v0.0.0-20261021122630-1ab1573eaf5b/go.mod h1:tZ+T8ljAM9ZE0VriHSWM4vfvhlq5zv5bNcPKqwZK1lk= github.com/fluxcd/pkg/ssa v0.80.0 h1:GgakpCFz4pY+unpOscPYlc5eVLUsB7hw9TmWSNr3yp8= github.com/fluxcd/pkg/ssa v0.80.0/go.mod h1:mUwKP2+KZXm1mal1gSs9SjgcexyqzXbPfHKxdFv5ntM= +github.com/fluxcd/pkg/tar v1.3.0 h1:PbL5jNN5q/F6nXA8aCH+msM75BS6lSnPX2RNd48f8ls= +github.com/fluxcd/pkg/tar v1.3.0/go.mod h1:CbN3lQVWrYBZ4CwcK7ZzpxA++WiiM1RQHDOtNPJWuno= github.com/fluxcd/pkg/testserver v0.15.0 h1:6Pao9oK1Cu5ssh9c7QoDnnyfPUtxHbw7gJ+5tSOmkWc= github.com/fluxcd/pkg/testserver v0.15.0/go.mod h1:uSiItABsGd13YQDkYpIWeV1mww6KEYcH9wk/L4p2PKw= github.com/fluxcd/source-controller/api v1.9.1-0.20261005091731-e47a59ad5d01 h1:QKNUNGjIV94G0JvE++lXtSzoEzyRlsEHMAuvo6I/VaM= @@ -294,9 +302,8 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= -github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -431,8 +438,8 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= -github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -506,7 +513,6 @@ golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go index fc734c4c2..526edc258 100644 --- a/internal/controller/helmrelease_controller.go +++ b/internal/controller/helmrelease_controller.go @@ -18,6 +18,7 @@ package controller import ( "context" + "crypto/tls" "errors" "fmt" "strings" @@ -106,6 +107,7 @@ type HelmReleaseReconciler struct { DependencyRequeueInterval time.Duration ArtifactFetchRetries int ArtifactFetchTimeout time.Duration + ArtifactServerTLSConfig *tls.Config // Feature gates @@ -330,7 +332,8 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, } // Load chart from artifact. - loadedChart, err := loader.SecureLoadChartFromURL(loader.NewRetryableHTTPClient(ctx, r.ArtifactFetchRetries, r.ArtifactFetchTimeout), source.GetArtifact().URL, source.GetArtifact().Digest) + loadedChart, err := loader.SecureLoadChartFromURL(ctx, source.GetArtifact().URL, source.GetArtifact().Digest, + r.ArtifactFetchRetries, r.ArtifactFetchTimeout, r.ArtifactServerTLSConfig) if err != nil { if errors.Is(err, loader.ErrFileNotFound) { msg := fmt.Sprintf("Source not ready: artifact not found. Retrying in %s", r.DependencyRequeueInterval.String()) diff --git a/internal/loader/artifact_url.go b/internal/loader/artifact_url.go index af63a4f5e..777debd29 100644 --- a/internal/loader/artifact_url.go +++ b/internal/loader/artifact_url.go @@ -17,22 +17,19 @@ limitations under the License. package loader import ( - "bytes" - _ "crypto/sha256" - _ "crypto/sha512" + "context" + "crypto/tls" "errors" "fmt" - "io" - "net/http" - "net/url" "os" "strings" + "time" - "github.com/hashicorp/go-retryablehttp" - digestlib "github.com/opencontainers/go-digest" - _ "github.com/opencontainers/go-digest/blake3" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" + ctrl "sigs.k8s.io/controller-runtime" + + fluxfetch "github.com/fluxcd/pkg/http/fetch" ) const ( @@ -50,87 +47,37 @@ const ( var ( // ErrFileNotFound is an error type used to signal 404 HTTP status code responses. ErrFileNotFound = errors.New("file not found") - // ErrIntegrity signals a chart loader failed to verify the integrity of - // a chart, for example due to a digest mismatch. - ErrIntegrity = errors.New("integrity failure") ) -// SecureLoadChartFromURL attempts to download a Helm chart from the given URL -// using the provided client. The retrieved data is verified against the given -// digest before loading the chart. It returns the loaded chart.Chart, or an -// error. The error may be of type ErrIntegrity if the integrity check fails. -func SecureLoadChartFromURL(client *retryablehttp.Client, URL, digest string) (*chart.Chart, error) { +// SecureLoadChartFromURL attempts to download a Helm chart from the given URL, +// verifies it against the given digest and loads it. The error may be of type +// ErrFileNotFound if the artifact does not exist. +func SecureLoadChartFromURL(ctx context.Context, URL, digest string, retries int, timeout time.Duration, tlsConfig *tls.Config) (*chart.Chart, error) { sourceLocalhost := os.Getenv(envSourceControllerLocalhost) if strings.Contains(URL, "//source-watcher") { sourceLocalhost = os.Getenv(envSourceWatcherLocalhost) } - u, err := overwriteHostname(URL, sourceLocalhost) - if err != nil { - return nil, err + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() } - req, err := retryablehttp.NewRequest(http.MethodGet, u, nil) - if err != nil { - return nil, err - } + fetcher := fluxfetch.New( + fluxfetch.WithLogger(ctrl.LoggerFrom(ctx)), + fluxfetch.WithRetries(retries), + fluxfetch.WithHostnameOverwrite(sourceLocalhost), + fluxfetch.WithTLSConfig(tlsConfig), + ) - resp, err := client.Do(req) - if err != nil || resp != nil && resp.StatusCode != http.StatusOK { - if err != nil { - return nil, err - } - _ = resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { + buf, err := fetcher.FetchBytes(ctx, URL, digest) + if err != nil { + if errors.Is(err, fluxfetch.ErrFileNotFound) { return nil, fmt.Errorf("failed to download chart from '%s': %w", URL, ErrFileNotFound) } - return nil, fmt.Errorf("failed to download chart from '%s' (status: %s)", URL, resp.Status) - } - - var c bytes.Buffer - if err := copyAndVerify(digest, resp.Body, &c); err != nil { - _ = resp.Body.Close() - return nil, err - } - - if err := resp.Body.Close(); err != nil { return nil, err } - return loader.LoadArchive(&c) -} - -// copyAndVerify copies the contents of reader to writer, and verifies the -// integrity of the data using the given digest. It returns an error if the -// integrity check fails. -func copyAndVerify(digest string, reader io.Reader, writer io.Writer) error { - dig, err := digestlib.Parse(digest) - if err != nil { - return fmt.Errorf("failed to parse digest '%s': %w", digest, err) - } - verifier := dig.Verifier() - mw := io.MultiWriter(verifier, writer) - if _, err := io.Copy(mw, reader); err != nil { - return fmt.Errorf("failed to copy and verify chart artifact: %w", err) - } - - if !verifier.Verified() { - return fmt.Errorf("%w: computed digest doesn't match '%s'", ErrIntegrity, dig) - } - return nil -} - -// overwriteHostname overwrites the hostname of the given URL with the given -// hostname. If the hostname is empty, the URL is returned unmodified. -func overwriteHostname(URL, hostname string) (string, error) { - if hostname == "" { - return URL, nil - } - - u, err := url.Parse(URL) - if err != nil { - return "", fmt.Errorf("failed to parse URL to overwrite hostname: %w", err) - } - u.Host = hostname - return u.String(), nil + return loader.LoadArchive(&buf) } diff --git a/internal/loader/artifact_url_test.go b/internal/loader/artifact_url_test.go index 9f4a3b291..3e1409044 100644 --- a/internal/loader/artifact_url_test.go +++ b/internal/loader/artifact_url_test.go @@ -17,16 +17,15 @@ limitations under the License. package loader import ( - "bytes" + "context" "errors" - "io" "net/http" "net/http/httptest" "os" "strings" "testing" + "time" - "github.com/hashicorp/go-retryablehttp" . "github.com/onsi/gomega" digestlib "github.com/opencontainers/go-digest" ) @@ -41,32 +40,48 @@ func TestSecureLoadChartFromURL(t *testing.T) { const chartPath = "/chart.tgz" const notFoundPath = "/not-found.tgz" - server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - if req.URL.Path == chartPath { + const slowPath = "/slow.tgz" + handler := http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case chartPath: res.WriteHeader(http.StatusOK) _, _ = res.Write(b) - return - } - if req.URL.Path == notFoundPath { + case notFoundPath: res.WriteHeader(http.StatusNotFound) - return + case slowPath: + time.Sleep(200 * time.Millisecond) + res.WriteHeader(http.StatusOK) + _, _ = res.Write(b) + default: + res.WriteHeader(http.StatusInternalServerError) } - res.WriteHeader(http.StatusInternalServerError) - })) - t.Cleanup(func() { - server.Close() }) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) chartURL := server.URL + chartPath - client := retryablehttp.NewClient() - client.Logger = nil - client.RetryMax = 2 - t.Run("loads Helm chart from URL", func(t *testing.T) { g := NewWithT(t) - got, err := SecureLoadChartFromURL(client, chartURL, digest.String()) + got, err := SecureLoadChartFromURL(context.Background(), chartURL, digest.String(), 2, 5*time.Second, nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.Name()).To(Equal("chart")) + g.Expect(got.Metadata.Version).To(Equal("0.1.0")) + }) + + t.Run("loads Helm chart from URL over TLS", func(t *testing.T) { + g := NewWithT(t) + + tlsServer := httptest.NewTLSServer(handler) + t.Cleanup(tlsServer.Close) + + tlsConfig := tlsServer.Client().Transport.(*http.Transport).TLSClientConfig + + // The fetcher must upgrade the scheme from http to https. + httpURL := "http://" + strings.TrimPrefix(tlsServer.URL, "https://") + chartPath + got, err := SecureLoadChartFromURL(context.Background(), httpURL, digest.String(), 2, 5*time.Second, tlsConfig) g.Expect(err).ToNot(HaveOccurred()) g.Expect(got).ToNot(BeNil()) g.Expect(got.Name()).To(Equal("chart")) @@ -79,7 +94,7 @@ func TestSecureLoadChartFromURL(t *testing.T) { t.Setenv(envSourceControllerLocalhost, strings.TrimPrefix(server.URL, "http://")) wrongHostnameURL := "http://invalid.com" + chartPath - got, err := SecureLoadChartFromURL(client, wrongHostnameURL, digest.String()) + got, err := SecureLoadChartFromURL(context.Background(), wrongHostnameURL, digest.String(), 2, 5*time.Second, nil) g.Expect(err).ToNot(HaveOccurred()) g.Expect(got).ToNot(BeNil()) g.Expect(got.Name()).To(Equal("chart")) @@ -89,16 +104,15 @@ func TestSecureLoadChartFromURL(t *testing.T) { t.Run("error on chart data digest mismatch", func(t *testing.T) { g := NewWithT(t) - got, err := SecureLoadChartFromURL(client, chartURL, digestlib.SHA256.FromString("invalid").String()) + got, err := SecureLoadChartFromURL(context.Background(), chartURL, digestlib.SHA256.FromString("invalid").String(), 2, 5*time.Second, nil) g.Expect(err).To(HaveOccurred()) - g.Expect(errors.Is(err, ErrIntegrity)).To(BeTrue()) g.Expect(got).To(BeNil()) }) t.Run("file not found error on 404", func(t *testing.T) { g := NewWithT(t) - got, err := SecureLoadChartFromURL(client, server.URL+notFoundPath, digest.String()) + got, err := SecureLoadChartFromURL(context.Background(), server.URL+notFoundPath, digest.String(), 2, 5*time.Second, nil) g.Expect(errors.Is(err, ErrFileNotFound)).To(BeTrue()) g.Expect(got).To(BeNil()) }) @@ -106,114 +120,17 @@ func TestSecureLoadChartFromURL(t *testing.T) { t.Run("error on HTTP request failure", func(t *testing.T) { g := NewWithT(t) - got, err := SecureLoadChartFromURL(client, server.URL+"/invalid.tgz", digest.String()) + got, err := SecureLoadChartFromURL(context.Background(), server.URL+"/invalid.tgz", digest.String(), 2, 5*time.Second, nil) g.Expect(err).To(HaveOccurred()) g.Expect(errors.Is(err, ErrFileNotFound)).To(BeFalse()) g.Expect(got).To(BeNil()) }) -} -func Test_copyAndVerify(t *testing.T) { - g := NewWithT(t) - - tmpDir := t.TempDir() - closedF, err := os.CreateTemp(tmpDir, "closed.txt") - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(closedF.Close()).ToNot(HaveOccurred()) - - tests := []struct { - name string - digest string - in io.Reader - out io.Writer - wantErr bool - }{ - { - name: "digest match (SHA256)", - digest: digestlib.SHA256.FromString("foo").String(), - in: bytes.NewReader([]byte("foo")), - out: bytes.NewBuffer(nil), - }, - { - name: "digest match (SHA384)", - digest: digestlib.SHA384.FromString("foo").String(), - in: bytes.NewReader([]byte("foo")), - out: bytes.NewBuffer(nil), - }, - { - name: "digest match (SHA512)", - digest: digestlib.SHA512.FromString("foo").String(), - in: bytes.NewReader([]byte("foo")), - out: bytes.NewBuffer(nil), - }, - { - name: "digest match (BLAKE3)", - digest: digestlib.BLAKE3.FromString("foo").String(), - in: bytes.NewReader([]byte("foo")), - out: bytes.NewBuffer(nil), - }, - { - name: "digest mismatch", - digest: digestlib.SHA256.FromString("foo").String(), - in: bytes.NewReader([]byte("bar")), - out: io.Discard, - wantErr: true, - }, - { - name: "copy failure (closed file)", - digest: digestlib.SHA256.FromString("foo").String(), - in: bytes.NewReader([]byte("foo")), - out: closedF, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := NewWithT(t) - - err := copyAndVerify(tt.digest, tt.in, tt.out) - g.Expect(err != nil).To(Equal(tt.wantErr), err) - }) - } -} + t.Run("timeout", func(t *testing.T) { + g := NewWithT(t) -func Test_overwriteHostname(t *testing.T) { - tests := []struct { - name string - URL string - hostname string - want string - wantErr bool - }{ - { - name: "overwrite hostname", - URL: "http://example.com", - hostname: "localhost", - want: "http://localhost", - }, - { - name: "overwrite hostname with port", - URL: "http://example.com", - hostname: "localhost:9090", - want: "http://localhost:9090", - }, - { - name: "no hostname", - URL: "http://example.com", - hostname: "", - want: "http://example.com", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := overwriteHostname(tt.URL, tt.hostname) - if (err != nil) != tt.wantErr { - t.Errorf("overwriteHostname() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("overwriteHostname() got = %v, want %v", got, tt.want) - } - }) - } + got, err := SecureLoadChartFromURL(context.Background(), server.URL+slowPath, digest.String(), 0, 10*time.Millisecond, nil) + g.Expect(err).To(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) } diff --git a/internal/loader/client.go b/internal/loader/client.go deleted file mode 100644 index a9523b8e1..000000000 --- a/internal/loader/client.go +++ /dev/null @@ -1,66 +0,0 @@ -/* -Copyright 2023 The Flux authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package loader - -import ( - "context" - "time" - - "github.com/go-logr/logr" - "github.com/hashicorp/go-retryablehttp" - ctrl "sigs.k8s.io/controller-runtime" -) - -// NewRetryableHTTPClient returns a new retrying HTTP client for loading -// artifacts. The client will retry up to the given number of times before -// giving up. The context is used to log errors. The timeout is the maximum -// duration for HTTP requests. -func NewRetryableHTTPClient(ctx context.Context, retries int, timeout time.Duration) *retryablehttp.Client { - httpClient := retryablehttp.NewClient() - httpClient.RetryWaitMin = 5 * time.Second - httpClient.RetryWaitMax = 30 * time.Second - httpClient.RetryMax = retries - httpClient.HTTPClient.Timeout = timeout - httpClient.Logger = newLoggerForContext(ctx) - return httpClient -} - -func newLoggerForContext(ctx context.Context) retryablehttp.LeveledLogger { - return &errorLogger{log: ctrl.LoggerFrom(ctx)} -} - -// errorLogger is a wrapper around logr.Logger that implements the -// retryablehttp.LeveledLogger interface while only logging errors. -type errorLogger struct { - log logr.Logger -} - -func (l *errorLogger) Error(msg string, keysAndValues ...any) { - l.log.Info(msg, keysAndValues...) -} - -func (l *errorLogger) Info(msg string, keysAndValues ...any) { - // Do nothing. -} - -func (l *errorLogger) Debug(msg string, keysAndValues ...any) { - // Do nothing. -} - -func (l *errorLogger) Warn(msg string, keysAndValues ...any) { - // Do nothing. -} diff --git a/main.go b/main.go index 9b9ffb23b..001bcf28c 100644 --- a/main.go +++ b/main.go @@ -17,12 +17,14 @@ limitations under the License. package main import ( + "crypto/tls" "errors" "fmt" "os" "time" flag "github.com/spf13/pflag" + "github.com/spiffe/go-spiffe/v2/workloadapi" "helm.sh/helm/v4/pkg/kube" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" @@ -50,6 +52,7 @@ import ( "github.com/fluxcd/pkg/runtime/metrics" "github.com/fluxcd/pkg/runtime/pprof" "github.com/fluxcd/pkg/runtime/probes" + fluxspiffe "github.com/fluxcd/pkg/spiffe" sourcev1 "github.com/fluxcd/source-controller/api/v1" // +kubebuilder:scaffold:imports @@ -110,6 +113,8 @@ func main() { disallowedFieldManagers []string tokenCacheOptions cache.TokenFlags defaultKubeConfigServiceAccount string + authorizedArtifactServers []string + authorizedEventServers []string ) flag.StringVar(&metricsAddr, "metrics-addr", ":8080", @@ -126,7 +131,7 @@ func main() { "The duration given to the reconciler to finish before forcibly stopping.") flag.IntVar(&httpRetry, "http-retry", 9, "The maximum number of retries when failing to fetch artifacts over HTTP.") - flag.DurationVar(&httpTimeout, "http-timeout", 30*time.Second, + flag.DurationVar(&httpTimeout, "http-timeout", 60*time.Second, "The timeout for HTTP requests when fetching artifacts.") flag.StringVar(&intkube.DefaultServiceAccountName, auth.ControllerFlagDefaultServiceAccount, "", "Default service account used for impersonation.") @@ -144,6 +149,10 @@ func main() { "The algorithm to use to calculate the digest of Helm release storage snapshots.") flag.StringArrayVar(&disallowedFieldManagers, "override-manager", []string{}, "List of field managers to override during drift detection.") + flag.StringArrayVar(&authorizedArtifactServers, fluxspiffe.ControllerFlagAuthorizedArtifactServerSPIFFEID, nil, + "SPIFFE ID of an artifact server authorized to serve artifacts. Can be repeated.") + flag.StringArrayVar(&authorizedEventServers, fluxspiffe.ControllerFlagAuthorizedEventServerSPIFFEID, nil, + "SPIFFE ID of an event server authorized to receive events. Can be repeated.") clientOptions.BindFlags(flag.CommandLine) logOptions.BindFlags(flag.CommandLine) @@ -317,11 +326,6 @@ func main() { probes.SetupChecks(mgr, setupLog) metricsH := helper.NewMetrics(mgr, metrics.MustMakeRecorder(), v2.HelmReleaseFinalizer) - eventRecorder, err := events.NewRecorder(ctrl.Log, eventsAddr, controllerName, events.WithManager(mgr)) - if err != nil { - setupLog.Error(err, "unable to create event recorder") - os.Exit(1) - } ctx := ctrl.SetupSignalHandler() if ok, _ := features.Enabled(features.OOMWatch); ok { @@ -340,6 +344,48 @@ func main() { ctx = ow.Watch(ctx) } + var artifactServerTLSConfig, eventTLSConfig *tls.Config + if len(authorizedArtifactServers) > 0 || len(authorizedEventServers) > 0 { + source, err := workloadapi.NewX509Source(ctx) + if err != nil { + setupLog.Error(err, "unable to create SPIFFE X.509 source") + os.Exit(1) + } + defer func() { + if err := source.Close(); err != nil { + setupLog.Error(err, "unable to close SPIFFE X.509 source") + } + }() + + if len(authorizedArtifactServers) > 0 { + artifactServerTLSConfig, err = fluxspiffe.BuildTLSConfig(source, authorizedArtifactServers...) + if err != nil { + setupLog.Error(err, "unable to configure SPIFFE mTLS for the artifact fetcher") + os.Exit(1) + } + setupLog.Info("artifact fetcher will use inter-controller mTLS via SPIFFE", "spiffeIDs", authorizedArtifactServers) + } + + if len(authorizedEventServers) > 0 { + eventTLSConfig, err = fluxspiffe.BuildTLSConfig(source, authorizedEventServers...) + if err != nil { + setupLog.Error(err, "unable to configure SPIFFE mTLS for the event server") + os.Exit(1) + } + setupLog.Info("event server will use inter-controller mTLS via SPIFFE", "spiffeIDs", authorizedEventServers) + } + } + + eventRecorderOpts := []events.RecorderOption{events.WithManager(mgr)} + if eventTLSConfig != nil { + eventRecorderOpts = append(eventRecorderOpts, events.WithTLSConfig(eventTLSConfig)) + } + eventRecorder, err := events.NewRecorder(ctrl.Log, eventsAddr, controllerName, eventRecorderOpts...) + if err != nil { + setupLog.Error(err, "unable to create event recorder") + os.Exit(1) + } + var tokenCache *cache.TokenCache if tokenCacheOptions.MaxSize > 0 { var err error @@ -394,6 +440,7 @@ func main() { DependencyRequeueInterval: requeueDependency, ArtifactFetchRetries: httpRetry, ArtifactFetchTimeout: httpTimeout, + ArtifactServerTLSConfig: artifactServerTLSConfig, AllowExternalArtifact: allowExternalArtifact, DisallowedFieldManagers: disallowedFieldManagers, }).SetupWithManager(ctx, mgr, controller.HelmReleaseReconcilerOptions{