blob: 2800becc3db65c4a8b109e6218ed74ebe0e93492 [file] [log] [blame]
// +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/<PROJECT-NUMBER>/zones/<ZONE>
parts := strings.Split(data, "/")
if len(parts) == 0 {
logger.Warningf("GET %s returned {%s}, does not match expected format {projects/<PROJECT-NUMBER>/zones/<ZONE>}", 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
}