// +build go1.13 /* * * Copyright 2020 gRPC 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 meshca import ( "bytes" "encoding/json" "errors" "fmt" "io/ioutil" "net/http" "net/http/httputil" "path" "strings" "time" v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" "github.com/golang/protobuf/jsonpb" "github.com/golang/protobuf/ptypes" "google.golang.org/grpc/credentials/sts" configpb "google.golang.org/grpc/credentials/tls/certprovider/meshca/internal/meshca_experimental" ) const ( // GKE metadata server endpoint. mdsBaseURI = "http://metadata.google.internal/" mdsRequestTimeout = 5 * time.Second // The following are default values used in the interaction with MeshCA. defaultMeshCaEndpoint = "meshca.googleapis.com" defaultCallTimeout = 10 * time.Second defaultCertLifetime = 24 * time.Hour defaultCertGraceTime = 12 * time.Hour defaultKeyTypeRSA = "RSA" defaultKeySize = 2048 // The following are default values used in the interaction with STS or // Secure Token Service, which is used to exchange the JWT token for an // access token. defaultSTSEndpoint = "securetoken.googleapis.com" defaultCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform" defaultRequestedTokenType = "urn:ietf:params:oauth:token-type:access_token" defaultSubjectTokenType = "urn:ietf:params:oauth:token-type:jwt" ) // For overriding in unit tests. var ( makeHTTPDoer = makeHTTPClient readZoneFunc = readZone readAudienceFunc = readAudience ) // Implements the certprovider.StableConfig interface. type pluginConfig struct { serverURI string stsOpts sts.Options callTimeout time.Duration certLifetime time.Duration certGraceTime time.Duration keyType string keySize int location string } // pluginConfigFromJSON parses the provided config in JSON. // // For certain values missing in the config, we use default values defined at // the top of this file. // // If the location field or STS audience field is missing, we try talking to the // GKE Metadata server and try to infer these values. If this attempt does not // succeed, we let those fields have empty values. func pluginConfigFromJSON(data json.RawMessage) (*pluginConfig, error) { cfgProto := &configpb.GoogleMeshCaConfig{} m := jsonpb.Unmarshaler{AllowUnknownFields: true} if err := m.Unmarshal(bytes.NewReader(data), cfgProto); err != nil { return nil, fmt.Errorf("meshca: failed to unmarshal config: %v", err) } if api := cfgProto.GetServer().GetApiType(); api != v3corepb.ApiConfigSource_GRPC { return nil, fmt.Errorf("meshca: server has apiType %s, want %s", api, v3corepb.ApiConfigSource_GRPC) } pc := &pluginConfig{} gs := cfgProto.GetServer().GetGrpcServices() if l := len(gs); l != 1 { return nil, fmt.Errorf("meshca: number of gRPC services in config is %d, expected 1", l) } grpcService := gs[0] googGRPC := grpcService.GetGoogleGrpc() if googGRPC == nil { return nil, errors.New("meshca: missing google gRPC service in config") } pc.serverURI = googGRPC.GetTargetUri() if pc.serverURI == "" { pc.serverURI = defaultMeshCaEndpoint } callCreds := googGRPC.GetCallCredentials() if len(callCreds) == 0 { return nil, errors.New("meshca: missing call credentials in config") } var stsCallCreds *v3corepb.GrpcService_GoogleGrpc_CallCredentials_StsService for _, cc := range callCreds { if stsCallCreds = cc.GetStsService(); stsCallCreds != nil { break } } if stsCallCreds == nil { return nil, errors.New("meshca: missing STS call credentials in config") } if stsCallCreds.GetSubjectTokenPath() == "" { return nil, errors.New("meshca: missing subjectTokenPath in STS call credentials config") } pc.stsOpts = makeStsOptsWithDefaults(stsCallCreds) var err error if pc.callTimeout, err = ptypes.Duration(grpcService.GetTimeout()); err != nil { pc.callTimeout = defaultCallTimeout } if pc.certLifetime, err = ptypes.Duration(cfgProto.GetCertificateLifetime()); err != nil { pc.certLifetime = defaultCertLifetime } if pc.certGraceTime, err = ptypes.Duration(cfgProto.GetRenewalGracePeriod()); err != nil { pc.certGraceTime = defaultCertGraceTime } switch cfgProto.GetKeyType() { case configpb.GoogleMeshCaConfig_KEY_TYPE_UNKNOWN, configpb.GoogleMeshCaConfig_KEY_TYPE_RSA: pc.keyType = defaultKeyTypeRSA default: return nil, fmt.Errorf("meshca: unsupported key type: %s, only support RSA keys", pc.keyType) } pc.keySize = int(cfgProto.GetKeySize()) if pc.keySize == 0 { pc.keySize = defaultKeySize } pc.location = cfgProto.GetLocation() if pc.location == "" { pc.location = readZoneFunc(makeHTTPDoer()) } return pc, nil } func (pc *pluginConfig) canonical() []byte { return []byte(fmt.Sprintf("%s:%s:%s:%s:%s:%s:%d:%s", pc.serverURI, pc.stsOpts, pc.callTimeout, pc.certLifetime, pc.certGraceTime, pc.keyType, pc.keySize, pc.location)) } func makeStsOptsWithDefaults(stsCallCreds *v3corepb.GrpcService_GoogleGrpc_CallCredentials_StsService) sts.Options { opts := sts.Options{ TokenExchangeServiceURI: stsCallCreds.GetTokenExchangeServiceUri(), Resource: stsCallCreds.GetResource(), Audience: stsCallCreds.GetAudience(), Scope: stsCallCreds.GetScope(), RequestedTokenType: stsCallCreds.GetRequestedTokenType(), SubjectTokenPath: stsCallCreds.GetSubjectTokenPath(), SubjectTokenType: stsCallCreds.GetSubjectTokenType(), ActorTokenPath: stsCallCreds.GetActorTokenPath(), ActorTokenType: stsCallCreds.GetActorTokenType(), } // Use sane defaults for unspecified fields. if opts.TokenExchangeServiceURI == "" { opts.TokenExchangeServiceURI = defaultSTSEndpoint } if opts.Audience == "" { opts.Audience = readAudienceFunc(makeHTTPDoer()) } if opts.Scope == "" { opts.Scope = defaultCloudPlatformScope } if opts.RequestedTokenType == "" { opts.RequestedTokenType = defaultRequestedTokenType } if opts.SubjectTokenType == "" { opts.SubjectTokenType = defaultSubjectTokenType } return opts } // httpDoer wraps the single method on the http.Client type that we use. This // helps with overriding in unit tests. type httpDoer interface { Do(req *http.Request) (*http.Response, error) } func makeHTTPClient() httpDoer { return &http.Client{Timeout: mdsRequestTimeout} } func readMetadata(client httpDoer, uriPath string) (string, error) { req, err := http.NewRequest("GET", mdsBaseURI+uriPath, nil) if err != nil { return "", err } req.Header.Add("Metadata-Flavor", "Google") resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { dump, err := httputil.DumpRequestOut(req, false) if err != nil { logger.Warningf("Failed to dump HTTP request: %v", err) } logger.Warningf("Request %q returned status %v", dump, resp.StatusCode) } return string(body), err } func readZone(client httpDoer) string { zoneURI := "computeMetadata/v1/instance/zone" data, err := readMetadata(client, zoneURI) if err != nil { logger.Warningf("GET %s failed: %v", path.Join(mdsBaseURI, zoneURI), err) return "" } // The output returned by the metadata server looks like this: // projects//zones/ parts := strings.Split(data, "/") if len(parts) == 0 { logger.Warningf("GET %s returned {%s}, does not match expected format {projects//zones/}", path.Join(mdsBaseURI, zoneURI)) return "" } return parts[len(parts)-1] } // readAudience constructs the audience field to be used in the STS request, if // it is not specified in the plugin configuration. // // "identitynamespace:{TRUST_DOMAIN}:{GKE_CLUSTER_URL}" is the format of the // audience field. When workload identity is enabled on a GCP project, a default // trust domain is created whose value is "{PROJECT_ID}.svc.id.goog". The format // of the GKE_CLUSTER_URL is: // https://container.googleapis.com/v1/projects/{PROJECT_ID}/zones/{ZONE}/clusters/{CLUSTER_NAME}. func readAudience(client httpDoer) string { projURI := "computeMetadata/v1/project/project-id" project, err := readMetadata(client, projURI) if err != nil { logger.Warningf("GET %s failed: %v", path.Join(mdsBaseURI, projURI), err) return "" } trustDomain := fmt.Sprintf("%s.svc.id.goog", project) clusterURI := "computeMetadata/v1/instance/attributes/cluster-name" cluster, err := readMetadata(client, clusterURI) if err != nil { logger.Warningf("GET %s failed: %v", path.Join(mdsBaseURI, clusterURI), err) return "" } zone := readZoneFunc(client) clusterURL := fmt.Sprintf("https://container.googleapis.com/v1/projects/%s/zones/%s/clusters/%s", project, zone, cluster) audience := fmt.Sprintf("identitynamespace:%s:%s", trustDomain, clusterURL) return audience }