blob: 8ffa4436c3578408d9df49408ae2cafe284f4a8e [file] [edit]
/*
Copyright 2026 Google LLC
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
https://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 main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/GoogleCloudPlatform/galog"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/cfg"
"github.com/GoogleCloudPlatform/google-guest-agent/internal/metadata"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
wipb "github.com/GoogleCloudPlatform/google-guest-agent/cmd/mwlid_extension/proto/mwlid"
)
const (
// trustAnchorsKey endpoint contains a set of trusted certificates for peer
// X.509 certificate chain validation.
trustAnchorsKey = "instance/gce-workload-certificates/trust-anchors"
// workloadIdentitiesKey endpoint contains identities managed by the GCE
// control plane. This contains the X.509 certificate and the private key for
// the VM's trust domain.
workloadIdentitiesKey = "instance/gce-workload-certificates/workload-identities"
// configStatusKey contains status and any errors in the config values
// provided via the VM metadata.
configStatusKey = "instance/gce-workload-certificates/config-status"
// enableWorkloadCertsKey is set to true as custom metadata to enable
// automatic provisioning of credentials.
enableWorkloadCertsKey = "instance/attributes/enable-workload-certificate"
// defaultGRPCTimeout is the default timeout for grpc calls.
defaultGRPCTimeout = 10 * time.Second
)
// Status represents the state of gRPC service.
type Status int
const (
// ServiceUnknown means the gRPC service availability is unknown. This is
// used when we've attempted to connect to the service but it failed with a
// non-permanent error.
ServiceUnknown Status = iota
// ServiceUnavailable means the gRPC service is unavailable. This is set when
// we successfully connect to the service but it responds with a non-OK
// FAILED_PRECONDITION error.
ServiceUnavailable
// ServiceAvailable means the gRPC service is available.
ServiceAvailable
)
type grpcServerStatus struct {
mutex sync.Mutex
// status is the status of the gRPC service.
status Status
}
func (gs *grpcServerStatus) setStatus(s Status) {
gs.mutex.Lock()
defer gs.mutex.Unlock()
gs.status = s
}
func (gs *grpcServerStatus) serverStatus() Status {
gs.mutex.Lock()
defer gs.mutex.Unlock()
return gs.status
}
// RefresherJob implements scheduler interface for cert refresher.
type RefresherJob struct {
// outputOpts is the output directory name and symlink templates.
outputOpts outputOpts
mdsClient metadata.MDSClientInterface
// grpcServerStatus is used to track if the grpc server is available. This
// server is not an instance startup dependency and can become available
// later. It is used to track if we've already detected server existence
// successfully and if not, retry.
grpcServerStatus
// clientMutex is used to synchronize access to the grpcClient.
clientMutex sync.Mutex
// grpcClient is the client connection to the grpc server. This is used to
// cache the connection so it doesn't need to be recreated every time.
grpcClient *grpc.ClientConn
}
// outputOpts is a struct for output directory name and symlink templates.
type outputOpts struct {
contentDirPrefix, tempSymlinkPrefix, symlink string
}
// isEnabled returns true only if enable-workload-certificate metadata attribute
// is present and set to true.
func (j *RefresherJob) isEnabled(ctx context.Context) bool {
// Check if GRPC service is enabled first.
// If it's not, check if the fallback MDS service is enabled.
return j.isGRPCServiceEnabled(ctx) || j.isMDSServiceEnabled(ctx)
}
// readMetadata reads metadata value for [key] from MDS.
func (j *RefresherJob) readMetadata(ctx context.Context, key string) ([]byte, error) {
// GCE Workload Certificate endpoints return 412 Precondition failed if the VM
// was never configured with valid config values at least once. Without valid
// config values GCE cannot provision the workload certificates.
resp, err := j.mdsClient.GetKey(ctx, key, nil)
if err != nil {
return nil, fmt.Errorf("failed to GET %q from MDS with error: %w", key, err)
}
return []byte(resp), nil
}
/*
metadata key instance/gce-workload-certificates/workload-identities
MANAGED_WORKLOAD_IDENTITY_SPIFFE is of the format:
spiffe://POOL_ID.global.PROJECT_NUMBER.workload.id.goog/ns/NAMESPACE_ID/sa/MANAGED_IDENTITY_ID
{
"status": "OK", // Status of the response,
"workloadCredentials": { // Credentials for the VM's trust domains
"MANAGED_WORKLOAD_IDENTITY_SPIFFE": {
"certificatePem": "-----BEGIN CERTIFICATE-----datahere-----END CERTIFICATE-----",
"privateKeyPem": "-----BEGIN PRIVATE KEY-----datahere-----END PRIVATE KEY-----"
}
}
}
*/
// workloadCredential represents Workload Credentials in metadata.
type workloadCredential struct {
CertificatePem string `json:"certificatePem"`
PrivateKeyPem string `json:"privateKeyPem"`
}
// workloadIdentities represents Workload Identities in metadata.
type workloadIdentities struct {
Status string `json:"status"`
WorkloadCredentials map[string]workloadCredential `json:"workloadCredentials"`
}
/*
metadata key instance/gce-workload-certificates/trust-anchors
{
"status": "<status string>" // Status of the response,
"trustAnchors": { // Trust bundle for the VM's trust domains
"PEER_SPIFFE_TRUST_DOMAIN_1": {
"trustAnchorsPem" : "<Trust bundle containing the X.509 roots certificates>",
},
"PEER_SPIFFE_TRUST_DOMAIN_2": {
"trustAnchorsPem" : "<Trust bundle containing the X.509 roots certificates>",
}
}
}
*/
// trustAnchor represents one or more certificates in an arbitrary order in the metadata.
type trustAnchor struct {
TrustAnchorsPem string `json:"trustAnchorsPem"`
}
// workloadTrustedAnchors represents Workload Trusted Root Certs in metadata.
type workloadTrustedAnchors struct {
Status string `json:"status"`
TrustAnchors map[string]trustAnchor `json:"trustAnchors"`
}
// findDomain finds the anchor matching with the domain from spiffeID.
// spiffeID is of the form -
// spiffe://POOL_ID.global.PROJECT_NUMBER.workload.id.goog/ns/NAMESPACE_ID/sa/MANAGED_IDENTITY_ID
// where domain is POOL_ID.global.PROJECT_NUMBER.workload.id.goog and
// anchors is a map of various domains and their corresponding trust PEMs.
// However, if anchor map contains single entry it returns that without any check.
func findDomain(anchors map[string]trustAnchor, spiffeID string) (string, error) {
c := len(anchors)
for k := range anchors {
if c == 1 {
return k, nil
}
if strings.Contains(spiffeID, k) {
return k, nil
}
}
return "", fmt.Errorf("no matching trust anchor found")
}
// writeTrustAnchors parses the input data, finds the domain from spiffeID and
// writes ca_certificate.pem in the destDir for that domain.
func writeTrustAnchors(wtrcsMd []byte, destDir, spiffeID string) error {
wtrcs := workloadTrustedAnchors{}
if err := json.Unmarshal(wtrcsMd, &wtrcs); err != nil {
return fmt.Errorf("error unmarshaling workload trusted root certs: %w", err)
}
// Currently there's only one trust anchor but there could be multiple trust
// anchors in future. In either case we want the trust anchor with domain
// matching with the one in SPIFFE ID.
domain, err := findDomain(wtrcs.TrustAnchors, spiffeID)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(destDir, "ca_certificates.pem"), []byte(wtrcs.TrustAnchors[domain].TrustAnchorsPem), 0644)
}
// writeWorkloadIdentities parses the input data, writes the certificates.pem,
// private_key.pem files in the destDir, and returns the SPIFFE ID for which it
// wrote the certificates.
func writeWorkloadIdentities(destDir string, wisMd []byte) (string, error) {
var spiffeID string
wis := workloadIdentities{}
if err := json.Unmarshal(wisMd, &wis); err != nil {
return "", fmt.Errorf("error unmarshaling workload identities response: %w", err)
}
// Its guaranteed to have single entry in workload credentials map.
for k := range wis.WorkloadCredentials {
spiffeID = k
break
}
if err := os.WriteFile(filepath.Join(destDir, "certificates.pem"), []byte(wis.WorkloadCredentials[spiffeID].CertificatePem), 0644); err != nil {
return "", fmt.Errorf("error writing certificates.pem: %w", err)
}
// There are cases where customers want to hide the private key from the guest
// agent. In that case, we want to avoid writing the file at all, since the
// key will be empty.
privateKeyPem := wis.WorkloadCredentials[spiffeID].PrivateKeyPem
if privateKeyPem != "" {
if err := os.WriteFile(filepath.Join(destDir, "private_key.pem"), []byte(privateKeyPem), 0644); err != nil {
return "", fmt.Errorf("error writing private_key.pem: %w", err)
}
} else {
galog.Debugf("Not writing private_key.pem because it is empty")
}
return spiffeID, nil
}
func (j *RefresherJob) writeCredsFromMDS(ctx context.Context, contentDir, symlink string) error {
galog.Infof("Refreshing workload credentials from MDS endpoints...")
// Get status first so it can be written even when other endpoints are empty.
certConfigStatus, err := j.readMetadata(ctx, configStatusKey)
if err != nil {
// Return success when certs are not configured to avoid unnecessary systemd
// failed units.
galog.Warnf("Error getting config status, workload certificates may not be configured: %v", err)
return nil
}
galog.Debugf("Creating timestamp contents dir %s", contentDir)
if err := os.MkdirAll(contentDir, 0755); err != nil {
return fmt.Errorf("error creating contents dir: %w", err)
}
// Write config_status first even if remaining endpoints are empty.
galog.Debugf("Writing config status to %s", contentDir)
if err := os.WriteFile(filepath.Join(contentDir, "config_status"), certConfigStatus, 0644); err != nil {
return fmt.Errorf("error writing config_status: %w", err)
}
// Handles the edge case where the config values provided for the first time
// may be invalid. This ensures that the symlink directory always exists and
// contains the config_status to surface config errors to the VM.
if _, err := os.Stat(symlink); os.IsNotExist(err) {
galog.Infof("Creating new symlink %s", symlink)
if err := os.Symlink(contentDir, symlink); err != nil {
return fmt.Errorf("error creating symlink: %w", err)
}
}
// Now get the rest of the content.
galog.Debugf("Reading workload identities from MDS")
wisMd, err := j.readMetadata(ctx, workloadIdentitiesKey)
if err != nil {
return fmt.Errorf("error getting workload-identities: %w", err)
}
galog.Debugf("Writing workload identities to %s", contentDir)
spiffeID, err := writeWorkloadIdentities(contentDir, wisMd)
if err != nil {
return fmt.Errorf("failed to write workload identities with error: %w", err)
}
galog.Debugf("Reading trust anchors from MDS")
wtrcsMd, err := j.readMetadata(ctx, trustAnchorsKey)
if err != nil {
return fmt.Errorf("error getting workload-trust-anchors: %w", err)
}
galog.Debugf("Writing trust anchors to %s", contentDir)
if err := writeTrustAnchors(wtrcsMd, contentDir, spiffeID); err != nil {
return fmt.Errorf("failed to write trust anchors: %w", err)
}
return nil
}
func (j *RefresherJob) refreshCreds(ctx context.Context, opts outputOpts, now string) error {
contentDir, tempSymlink := j.generateTmpDirNames(opts, now)
// Scheduled job [isEnabled] could return true if we did not successfully
// determine the service status. In this case we should check the service
// status again before proceeding.
if j.isGRPCServiceEnabled(ctx) {
if err := j.refreshCredsWithGRPC(ctx, contentDir); err != nil {
return fmt.Errorf("refresh creds with gRPC error: %w", err)
}
} else if j.isMDSServiceEnabled(ctx) {
if err := j.writeCredsFromMDS(ctx, contentDir, opts.symlink); err != nil {
return fmt.Errorf("refresh creds with MDS error: %w", err)
}
} else {
galog.Debugf("Not refreshing workload certificates, service is not enabled")
return nil
}
// We fetched the credentials successfully, now we can rotate the symlink and
// remove the previous content dir.
galog.Debugf("Creating temporary symlink %s", tempSymlink)
if err := os.Symlink(contentDir, tempSymlink); err != nil {
return fmt.Errorf("error creating temporary link: %w", err)
}
oldTarget, err := os.Readlink(opts.symlink)
if err != nil {
galog.Warnf("Error reading existing symlink %q: %v", opts.symlink, err)
oldTarget = ""
}
// Only rotate on success of all steps above.
galog.Infof("Rotating symlink %s", opts.symlink)
galog.V(2).Debugf("Attempting to remove existing symlink %q", opts.symlink)
if err := os.Remove(opts.symlink); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("error removing symlink: %w", err)
}
galog.V(2).Debugf("Attempting to rename temporary symlink %q to %q", tempSymlink, opts.symlink)
if err := os.Rename(tempSymlink, opts.symlink); err != nil {
return fmt.Errorf("error rotating target link: %w", err)
}
// Clean up previous contents dir.
newTarget, err := os.Readlink(opts.symlink)
if err != nil {
return fmt.Errorf("error reading new symlink: %w, unable to remove old symlink target", err)
}
if oldTarget != newTarget {
galog.Infof("Removing old content dir %s", oldTarget)
if err := os.RemoveAll(oldTarget); err != nil {
return fmt.Errorf("failed to remove old symlink target: %w", err)
}
}
return nil
}
// newClient returns a cached client if present, otherwise creates a new grpc
// client connection to the MWLID service and caches it.
func (j *RefresherJob) newClient(ctx context.Context) (*grpc.ClientConn, error) {
j.clientMutex.Lock()
defer j.clientMutex.Unlock()
if j.grpcClient != nil {
return j.grpcClient, nil
}
creds := grpc.WithTransportCredentials(insecure.NewCredentials())
address := fmt.Sprintf("%s:%d", cfg.Retrieve().MWLID.ServiceIP, cfg.Retrieve().MWLID.ServicePort)
galog.Debugf("Creating gRPC client for MWLID service at %q", address)
conn, err := grpc.NewClient(address, creds)
if err != nil {
return nil, fmt.Errorf("failed to create gRPC client for MWLID service at %q: %w", address, err)
}
j.grpcClient = conn
return conn, nil
}
// isMDSServiceEnabled returns true if the preview version of the workload
// identity is enabled.
func (j *RefresherJob) isMDSServiceEnabled(ctx context.Context) bool {
resp, err := j.readMetadata(ctx, enableWorkloadCertsKey)
if err != nil {
galog.Debugf("Failed to get %q from MDS with error: %v", enableWorkloadCertsKey, err)
return false
}
return bytes.EqualFold(resp, []byte("true"))
}
// isGRPCServiceEnabled returns true if the MWLID service is enabled and the
// server is reachable.
func (j *RefresherJob) isGRPCServiceEnabled(ctx context.Context) bool {
if !cfg.Retrieve().MWLID.Enabled {
galog.Debugf("MWLID credential feature is disabled in config, skipping gRPC server check.")
return false
}
if currStatus := j.serverStatus(); currStatus != ServiceUnknown {
return currStatus == ServiceAvailable
}
conn, err := j.newClient(ctx)
if err != nil {
galog.Debugf("Failed to create gRPC client for MWLID service: %v", err)
return false
}
c := wipb.NewWorkloadIdentityClient(conn)
tCtx, cancel := context.WithTimeout(ctx, defaultGRPCTimeout)
defer cancel()
// Try calling any RPC to determine if the server is available.
_, err = c.GetWorkloadCertificates(tCtx, &wipb.GetWorkloadCertificatesRequest{}, grpc.WaitForReady(true))
if err == nil {
galog.Debugf("Successfully connected to MWLID service, gRPC server will be used for cert refresh.")
// We successfully connected to the server and it is available.
j.setStatus(ServiceAvailable)
return true
}
st, ok := status.FromError(err)
if ok && st.Code() == codes.FailedPrecondition {
// We got a permanent error, gRPC server is unavailable.
galog.Debugf("MWLID gRPC server is unavailable: [%v], MDS will be used for cert refresh.", err)
j.setStatus(ServiceUnavailable)
return false
}
galog.Debugf("Error when connecting to MWLID service: [%v], will retry determining server status.", err)
// We got an unknown error, this could be a timeout or other error when
// connecting to the server. We should retry determining server status.
return true
}
// closeClient closes and removes the cached grpc client connection.
func (j *RefresherJob) closeClient() {
j.clientMutex.Lock()
defer j.clientMutex.Unlock()
galog.Debug("Closing gRPC client connection to MWLID service")
if j.grpcClient == nil {
return
}
if err := j.grpcClient.Close(); err != nil {
galog.Warnf("Failed to close connection to MWLID service: %v", err)
}
j.grpcClient = nil
}
// refreshCredsWithGRPC refreshes the workload certificates using the MWLID
// service over gRPC.
func (j *RefresherJob) refreshCredsWithGRPC(ctx context.Context, contentDir string) error {
galog.Infof("Refreshing workload identity credentials from MWLID server...")
conn, err := j.newClient(ctx)
if err != nil {
return fmt.Errorf("failed to connect to snapshot service: %w", err)
}
c := wipb.NewWorkloadIdentityClient(conn)
// Close the client if we failed to make a successful call to the server. Next
// attempt to connect to the server will recreate the client.
tCtxCerts, cancelCerts := context.WithTimeout(ctx, defaultGRPCTimeout)
defer cancelCerts()
certs, err := c.GetWorkloadCertificates(tCtxCerts, &wipb.GetWorkloadCertificatesRequest{})
if err != nil {
j.closeClient()
return fmt.Errorf("failed to get workload certificates: %w", err)
}
tCtxBundle, cancelBundle := context.WithTimeout(ctx, defaultGRPCTimeout)
defer cancelBundle()
bundle, err := c.GetWorkloadTrustBundles(tCtxBundle, &wipb.GetWorkloadTrustBundlesRequest{})
if err != nil {
j.closeClient()
return fmt.Errorf("failed to get workload trust bundles: %w", err)
}
if err := os.MkdirAll(contentDir, 0755); err != nil {
return fmt.Errorf("error creating contents dir: %w", err)
}
// There are cases where customers want to hide the private key from the guest
// agent. In that case, we want to avoid writing the file at all, since the
// key will be empty.
privateKeyPem := certs.GetPrivateKeyPem()
if len(privateKeyPem) > 0 {
galog.Debugf("Writing workload certificates private key to %s", contentDir)
if err := os.WriteFile(filepath.Join(contentDir, "private_key.pem"), certs.GetPrivateKeyPem(), 0644); err != nil {
return fmt.Errorf("error writing private_key.pem: %w", err)
}
} else {
galog.Debugf("Not writing private_key.pem because it is empty")
}
galog.Debugf("Writing workload certificates certificate chain to %s", contentDir)
if err := os.WriteFile(filepath.Join(contentDir, "certificates.pem"), certs.GetCertificateChainPem(), 0644); err != nil {
return fmt.Errorf("error writing certificates.pem: %w", err)
}
galog.Debugf("Writing workload trust bundles to %s", contentDir)
if err := os.WriteFile(filepath.Join(contentDir, "trust_bundles.json"), bundle.GetSpiffeTrustBundlesMapJson(), 0644); err != nil {
return fmt.Errorf("error writing trust_bundles.json: %w", err)
}
return nil
}
// watchIdentity watches the identity in the VM.
//
// This puts a longpoll on the metadata server for the identity UUID. When the
// identity is changed, the hanging request will be returned and will notify the
// refresher job to refresh the credentials.
func (j *RefresherJob) watchIdentity(ctx context.Context) chan *metadata.Descriptor {
mdsChan := make(chan *metadata.Descriptor, 1)
go func() {
defer close(mdsChan) // Ensure the channel is closed when the go routine exits.
var prevUUID string
// Keep watching the identity until the context is cancelled.
for {
desc, err := j.mdsClient.Watch(ctx)
if err != nil {
galog.Warnf("Failed to watch identity: %v", err)
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
continue
}
// The refresher would've run once already; skip the first descriptor.
if prevUUID == "" {
prevUUID = desc.Instance().IdentityConfiguration().IdentityUUID()
continue
}
// Skip if the identity is the same as the previous one.
currUUID := desc.Instance().IdentityConfiguration().IdentityUUID()
if prevUUID == currUUID {
continue
}
galog.Info("Identity changed, refreshing credentials...")
prevUUID = currUUID
select {
case <-ctx.Done():
return
case mdsChan <- desc:
}
}
}()
return mdsChan
}