mirror of https://github.com/grpc/grpc-go.git
282 lines
9.2 KiB
Go
282 lines
9.2 KiB
Go
// +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
|
|
}
|