Add config entry for terminating gateways (#7545)

This config entry will be used to configure terminating gateways.

It accepts the name of the gateway and a list of services the gateway will represent.

For each service users will be able to specify: its name, namespace, and additional options for TLS origination.

Co-authored-by: Kyle Havlovitz <kylehav@gmail.com>
Co-authored-by: Chris Piraino <cpiraino@hashicorp.com>
This commit is contained in:
Freddy 2020-03-31 13:27:32 -06:00 committed by GitHub
parent c911174327
commit 90576060bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 756 additions and 20 deletions

View File

@ -182,6 +182,63 @@ func TestConfig_Apply(t *testing.T) {
}
}
func TestConfig_Apply_TerminatingGateway(t *testing.T) {
t.Parallel()
a := NewTestAgent(t, t.Name(), "")
defer a.Shutdown()
testrpc.WaitForTestAgent(t, a.RPC, "dc1")
// Create some config entries.
body := bytes.NewBuffer([]byte(`
{
"Kind": "terminating-gateway",
"Name": "west-gw-01",
"Services": [
{
"Name": "web",
"CAFile": "/etc/web/ca.crt",
"CertFile": "/etc/web/client.crt",
"KeyFile": "/etc/web/tls.key"
},
{
"Name": "api"
}
]
}`))
req, _ := http.NewRequest("PUT", "/v1/config", body)
resp := httptest.NewRecorder()
_, err := a.srv.ConfigApply(resp, req)
require.NoError(t, err)
require.Equal(t, 200, resp.Code, "!200 Response Code: %s", resp.Body.String())
// Get the remaining entry.
{
args := structs.ConfigEntryQuery{
Kind: structs.TerminatingGateway,
Name: "west-gw-01",
Datacenter: "dc1",
}
var out structs.ConfigEntryResponse
require.NoError(t, a.RPC("ConfigEntry.Get", &args, &out))
require.NotNil(t, out.Entry)
got := out.Entry.(*structs.TerminatingGatewayConfigEntry)
expect := []structs.LinkedService{
{
Name: "web",
CAFile: "/etc/web/ca.crt",
CertFile: "/etc/web/client.crt",
KeyFile: "/etc/web/tls.key",
},
{
Name: "api",
},
}
require.Equal(t, expect, got.Services)
}
}
func TestConfig_Apply_ProxyDefaultsMeshGateway(t *testing.T) {
t.Parallel()

View File

@ -2,7 +2,6 @@ package state
import (
"fmt"
"github.com/hashicorp/consul/agent/consul/discoverychain"
"github.com/hashicorp/consul/agent/structs"
memdb "github.com/hashicorp/go-memdb"
@ -326,6 +325,7 @@ func (s *Store) validateProposedConfigEntryInGraph(
case structs.ServiceSplitter:
case structs.ServiceResolver:
case structs.IngressGateway:
case structs.TerminatingGateway:
default:
return fmt.Errorf("unhandled kind %q during validation of %q", kind, name)
}

View File

@ -15,12 +15,13 @@ import (
)
const (
ServiceDefaults string = "service-defaults"
ProxyDefaults string = "proxy-defaults"
ServiceRouter string = "service-router"
ServiceSplitter string = "service-splitter"
ServiceResolver string = "service-resolver"
IngressGateway string = "ingress-gateway"
ServiceDefaults string = "service-defaults"
ProxyDefaults string = "proxy-defaults"
ServiceRouter string = "service-router"
ServiceSplitter string = "service-splitter"
ServiceResolver string = "service-resolver"
IngressGateway string = "ingress-gateway"
TerminatingGateway string = "terminating-gateway"
ProxyConfigGlobal string = "global"
@ -386,6 +387,15 @@ func ConfigEntryDecodeRulesForKind(kind string) (skipWhenPatching []string, tran
}, map[string]string{
"service_subset": "servicesubset",
}, nil
case TerminatingGateway:
return []string{
"services",
"Services",
}, map[string]string{
"ca_file": "cafile",
"cert_file": "certfile",
"key_file": "keyfile",
}, nil
default:
return nil, nil, fmt.Errorf("kind %q should be explicitly handled here", kind)
}
@ -478,6 +488,8 @@ func MakeConfigEntry(kind, name string) (ConfigEntry, error) {
return &ServiceResolverConfigEntry{Name: name}, nil
case IngressGateway:
return &IngressGatewayConfigEntry{Name: name}, nil
case TerminatingGateway:
return &TerminatingGatewayConfigEntry{Name: name}, nil
default:
return nil, fmt.Errorf("invalid config entry kind: %s", kind)
}
@ -489,7 +501,7 @@ func ValidateConfigEntryKind(kind string) bool {
return true
case ServiceRouter, ServiceSplitter, ServiceResolver:
return true
case IngressGateway:
case IngressGateway, TerminatingGateway:
return true
default:
return false

View File

@ -159,3 +159,121 @@ func (e *IngressGatewayConfigEntry) GetEnterpriseMeta() *EnterpriseMeta {
return &e.EnterpriseMeta
}
// TerminatingGatewayConfigEntry manages the configuration for a terminating service
// with the given name.
type TerminatingGatewayConfigEntry struct {
Kind string
Name string
Services []LinkedService
EnterpriseMeta `hcl:",squash" mapstructure:",squash"`
RaftIndex
}
// A LinkedService is a service represented by a terminating gateway
type LinkedService struct {
// Name is the name of the service, as defined in Consul's catalog
Name string `json:",omitempty"`
// CAFile is the optional path to a CA certificate to use for TLS connections
// from the gateway to the linked service
CAFile string `json:",omitempty"`
// CertFile is the optional path to a client certificate to use for TLS connections
// from the gateway to the linked service
CertFile string `json:",omitempty"`
// KeyFile is the optional path to a private key to use for TLS connections
// from the gateway to the linked service
KeyFile string `json:",omitempty"`
EnterpriseMeta `hcl:",squash" mapstructure:",squash"`
}
func (e *TerminatingGatewayConfigEntry) GetKind() string {
return TerminatingGateway
}
func (e *TerminatingGatewayConfigEntry) GetName() string {
if e == nil {
return ""
}
return e.Name
}
func (e *TerminatingGatewayConfigEntry) Normalize() error {
if e == nil {
return fmt.Errorf("config entry is nil")
}
e.Kind = TerminatingGateway
for i := range e.Services {
e.Services[i].EnterpriseMeta.Normalize()
}
e.EnterpriseMeta.Normalize()
return nil
}
func (e *TerminatingGatewayConfigEntry) Validate() error {
seen := make(map[ServiceID]bool)
for _, svc := range e.Services {
if svc.Name == "" {
return fmt.Errorf("Service name cannot be blank.")
}
ns := svc.NamespaceOrDefault()
if ns == WildcardSpecifier {
return fmt.Errorf("Wildcard namespace is not supported for terminating gateway services")
}
// Check for duplicates within the entry
cid := NewServiceID(svc.Name, &svc.EnterpriseMeta)
if ok := seen[cid]; ok {
return fmt.Errorf("Service %q was specified more than once within a namespace", cid.String())
}
seen[cid] = true
// If any TLS config flag was specified, all must be
if (svc.CAFile != "" || svc.CertFile != "" || svc.KeyFile != "") &&
!(svc.CAFile != "" && svc.CertFile != "" && svc.KeyFile != "") {
return fmt.Errorf("Service %q must have a CertFile, CAFile, and KeyFile specified for TLS origination", svc.Name)
}
}
return nil
}
func (e *TerminatingGatewayConfigEntry) CanRead(authz acl.Authorizer) bool {
var authzContext acl.AuthorizerContext
e.FillAuthzContext(&authzContext)
return authz.OperatorRead(&authzContext) == acl.Allow
}
func (e *TerminatingGatewayConfigEntry) CanWrite(authz acl.Authorizer) bool {
var authzContext acl.AuthorizerContext
e.FillAuthzContext(&authzContext)
return authz.OperatorWrite(&authzContext) == acl.Allow
}
func (e *TerminatingGatewayConfigEntry) GetRaftIndex() *RaftIndex {
if e == nil {
return &RaftIndex{}
}
return &e.RaftIndex
}
func (e *TerminatingGatewayConfigEntry) GetEnterpriseMeta() *EnterpriseMeta {
if e == nil {
return nil
}
return &e.EnterpriseMeta
}

View File

@ -185,3 +185,161 @@ func TestIngressConfigEntry_Validate(t *testing.T) {
})
}
}
func TestTerminatingConfigEntry_Validate(t *testing.T) {
t.Parallel()
cases := []struct {
name string
entry TerminatingGatewayConfigEntry
expectErr string
}{
{
name: "service conflict",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "foo",
},
{
Name: "foo",
},
},
},
expectErr: "specified more than once",
},
{
name: "blank service name",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "",
},
},
},
expectErr: "Service name cannot be blank.",
},
{
name: "not all TLS options provided-1",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "web",
CAFile: "ca.crt",
},
},
},
expectErr: "must have a CertFile, CAFile, and KeyFile",
},
{
name: "not all TLS options provided-2",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "web",
CertFile: "client.crt",
},
},
},
expectErr: "must have a CertFile, CAFile, and KeyFile",
},
{
name: "not all TLS options provided-3",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "web",
KeyFile: "tls.key",
},
},
},
expectErr: "must have a CertFile, CAFile, and KeyFile",
},
{
name: "not all TLS options provided-4",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "web",
CAFile: "ca.crt",
KeyFile: "tls.key",
},
},
},
expectErr: "must have a CertFile, CAFile, and KeyFile",
},
{
name: "not all TLS options provided-5",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "web",
CAFile: "ca.crt",
CertFile: "client.crt",
},
},
},
expectErr: "must have a CertFile, CAFile, and KeyFile",
},
{
name: "not all TLS options provided-6",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "web",
KeyFile: "tls.key",
CertFile: "client.crt",
},
},
},
expectErr: "must have a CertFile, CAFile, and KeyFile",
},
{
name: "all TLS options provided",
entry: TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "web",
CAFile: "ca.crt",
CertFile: "client.crt",
KeyFile: "tls.key",
},
},
},
},
}
for _, test := range cases {
// We explicitly copy the variable for the range statement so that can run
// tests in parallel.
tc := test
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := tc.entry.Validate()
if tc.expectErr != "" {
require.Error(t, err)
requireContainsLower(t, err.Error(), tc.expectErr)
} else {
require.NoError(t, err)
}
})
}
}

View File

@ -647,6 +647,63 @@ func TestDecodeConfigEntry(t *testing.T) {
},
},
},
{
name: "terminating-gateway: kitchen sink",
snake: `
kind = "terminating-gateway"
name = "terminating-gw-west"
services = [
{
name = "payments",
ca_file = "/etc/payments/ca.pem",
cert_file = "/etc/payments/cert.pem",
key_file = "/etc/payments/tls.key",
},
{
name = "*",
ca_file = "/etc/all/ca.pem",
cert_file = "/etc/all/cert.pem",
key_file = "/etc/all/tls.key",
},
]
`,
camel: `
Kind = "terminating-gateway"
Name = "terminating-gw-west"
Services = [
{
Name = "payments",
CAFile = "/etc/payments/ca.pem",
CertFile = "/etc/payments/cert.pem",
KeyFile = "/etc/payments/tls.key",
},
{
Name = "*",
CAFile = "/etc/all/ca.pem",
CertFile = "/etc/all/cert.pem",
KeyFile = "/etc/all/tls.key",
},
]
`,
expect: &TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Services: []LinkedService{
{
Name: "payments",
CAFile: "/etc/payments/ca.pem",
CertFile: "/etc/payments/cert.pem",
KeyFile: "/etc/payments/tls.key",
},
{
Name: "*",
CAFile: "/etc/all/ca.pem",
CertFile: "/etc/all/cert.pem",
KeyFile: "/etc/all/tls.key",
},
},
},
},
} {
tc := tc

View File

@ -12,12 +12,13 @@ import (
)
const (
ServiceDefaults string = "service-defaults"
ProxyDefaults string = "proxy-defaults"
ServiceRouter string = "service-router"
ServiceSplitter string = "service-splitter"
ServiceResolver string = "service-resolver"
IngressGateway string = "ingress-gateway"
ServiceDefaults string = "service-defaults"
ProxyDefaults string = "proxy-defaults"
ServiceRouter string = "service-router"
ServiceSplitter string = "service-splitter"
ServiceResolver string = "service-resolver"
IngressGateway string = "ingress-gateway"
TerminatingGateway string = "terminating-gateway"
ProxyConfigGlobal string = "global"
)
@ -141,11 +142,6 @@ func (p *ProxyConfigEntry) GetModifyIndex() uint64 {
return p.ModifyIndex
}
type rawEntryListResponse struct {
kind string
Entries []map[string]interface{}
}
func makeConfigEntry(kind, name string) (ConfigEntry, error) {
switch kind {
case ServiceDefaults:
@ -160,6 +156,8 @@ func makeConfigEntry(kind, name string) (ConfigEntry, error) {
return &ServiceResolverConfigEntry{Kind: kind, Name: name}, nil
case IngressGateway:
return &IngressGatewayConfigEntry{Kind: kind, Name: name}, nil
case TerminatingGateway:
return &TerminatingGatewayConfigEntry{Kind: kind, Name: name}, nil
default:
return nil, fmt.Errorf("invalid config entry kind: %s", kind)
}

View File

@ -82,3 +82,67 @@ func (i *IngressGatewayConfigEntry) GetCreateIndex() uint64 {
func (i *IngressGatewayConfigEntry) GetModifyIndex() uint64 {
return i.ModifyIndex
}
// TerminatingGatewayConfigEntry manages the configuration for a terminating gateway
// with the given name.
type TerminatingGatewayConfigEntry struct {
// Kind of the config entry. This should be set to api.TerminatingGateway.
Kind string
// Name is used to match the config entry with its associated terminating gateway
// service. This should match the name provided in the service definition.
Name string
// Services is a list of service names represented by the terminating gateway.
Services []LinkedService `json:",omitempty"`
// CreateIndex is the Raft index this entry was created at. This is a
// read-only field.
CreateIndex uint64
// ModifyIndex is used for the Check-And-Set operations and can also be fed
// back into the WaitIndex of the QueryOptions in order to perform blocking
// queries.
ModifyIndex uint64
// Namespace is the namespace the config entry is associated with
// Namespacing is a Consul Enterprise feature.
Namespace string `json:",omitempty"`
}
// A LinkedService is a service represented by a terminating gateway
type LinkedService struct {
// The namespace the service is registered in
Namespace string `json:",omitempty"`
// Name is the name of the service, as defined in Consul's catalog
Name string `json:",omitempty"`
// CAFile is the optional path to a CA certificate to use for TLS connections
// from the gateway to the linked service
CAFile string `json:",omitempty"`
// CertFile is the optional path to a client certificate to use for TLS connections
// from the gateway to the linked service
CertFile string `json:",omitempty"`
// KeyFile is the optional path to a private key to use for TLS connections
// from the gateway to the linked service
KeyFile string `json:",omitempty"`
}
func (g *TerminatingGatewayConfigEntry) GetKind() string {
return g.Kind
}
func (g *TerminatingGatewayConfigEntry) GetName() string {
return g.Name
}
func (g *TerminatingGatewayConfigEntry) GetCreateIndex() uint64 {
return g.CreateIndex
}
func (g *TerminatingGatewayConfigEntry) GetModifyIndex() uint64 {
return g.ModifyIndex
}

View File

@ -125,3 +125,117 @@ func TestAPI_ConfigEntries_IngressGateway(t *testing.T) {
entry, qm, err = config_entries.Get(IngressGateway, "foo", nil)
require.Error(t, err)
}
func TestAPI_ConfigEntries_TerminatingGateway(t *testing.T) {
t.Parallel()
c, s := makeClient(t)
defer s.Stop()
configEntries := c.ConfigEntries()
terminating1 := &TerminatingGatewayConfigEntry{
Kind: TerminatingGateway,
Name: "foo",
}
terminating2 := &TerminatingGatewayConfigEntry{
Kind: TerminatingGateway,
Name: "bar",
}
// set it
_, wm, err := configEntries.Set(terminating1, nil)
require.NoError(t, err)
require.NotNil(t, wm)
require.NotEqual(t, 0, wm.RequestTime)
// also set the second one
_, wm, err = configEntries.Set(terminating2, nil)
require.NoError(t, err)
require.NotNil(t, wm)
require.NotEqual(t, 0, wm.RequestTime)
// get it
entry, qm, err := configEntries.Get(TerminatingGateway, "foo", nil)
require.NoError(t, err)
require.NotNil(t, qm)
require.NotEqual(t, 0, qm.RequestTime)
// verify it
readTerminating, ok := entry.(*TerminatingGatewayConfigEntry)
require.True(t, ok)
require.Equal(t, terminating1.Kind, readTerminating.Kind)
require.Equal(t, terminating1.Name, readTerminating.Name)
// update it
terminating1.Services = []LinkedService{
{
Name: "web",
CAFile: "/etc/web/ca.crt",
CertFile: "/etc/web/client.crt",
KeyFile: "/etc/web/tls.key",
},
}
// CAS fail
written, _, err := configEntries.CAS(terminating1, 0, nil)
require.NoError(t, err)
require.False(t, written)
// CAS success
written, wm, err = configEntries.CAS(terminating1, readTerminating.ModifyIndex, nil)
require.NoError(t, err)
require.NotNil(t, wm)
require.NotEqual(t, 0, wm.RequestTime)
require.True(t, written)
// update no cas
terminating2.Services = []LinkedService{
{
Name: "*",
CAFile: "/etc/certs/ca.crt",
CertFile: "/etc/certs/client.crt",
KeyFile: "/etc/certs/tls.key",
},
}
_, wm, err = configEntries.Set(terminating2, nil)
require.NoError(t, err)
require.NotNil(t, wm)
require.NotEqual(t, 0, wm.RequestTime)
// list them
entries, qm, err := configEntries.List(TerminatingGateway, nil)
require.NoError(t, err)
require.NotNil(t, qm)
require.NotEqual(t, 0, qm.RequestTime)
require.Len(t, entries, 2)
for _, entry = range entries {
switch entry.GetName() {
case "foo":
// this also verifies that the update value was persisted and
// the updated values are seen
readTerminating, ok = entry.(*TerminatingGatewayConfigEntry)
require.True(t, ok)
require.Equal(t, terminating1.Kind, readTerminating.Kind)
require.Equal(t, terminating1.Name, readTerminating.Name)
require.Equal(t, terminating1.Services, readTerminating.Services)
case "bar":
readTerminating, ok = entry.(*TerminatingGatewayConfigEntry)
require.True(t, ok)
require.Equal(t, terminating2.Kind, readTerminating.Kind)
require.Equal(t, terminating2.Name, readTerminating.Name)
require.Equal(t, terminating2.Services, readTerminating.Services)
}
}
// delete it
wm, err = configEntries.Delete(TerminatingGateway, "foo", nil)
require.NoError(t, err)
require.NotNil(t, wm)
require.NotEqual(t, 0, wm.RequestTime)
// verify deletion
entry, qm, err = configEntries.Get(TerminatingGateway, "foo", nil)
require.Error(t, err)
}

View File

@ -674,6 +674,50 @@ func TestDecodeConfigEntry(t *testing.T) {
},
},
},
{
name: "terminating-gateway",
body: `
{
"Kind": "terminating-gateway",
"Name": "terminating-west",
"Services": [
{
"Namespace": "foo",
"Name": "web",
"CAFile": "/etc/ca.pem",
"CertFile": "/etc/cert.pem",
"KeyFile": "/etc/tls.key"
},
{
"Name": "api"
},
{
"Namespace": "bar",
"Name": "*"
}
]
}`,
expect: &TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-west",
Services: []LinkedService{
{
Namespace: "foo",
Name: "web",
CAFile: "/etc/ca.pem",
CertFile: "/etc/cert.pem",
KeyFile: "/etc/tls.key",
},
{
Name: "api",
},
{
Namespace: "bar",
Name: "*",
},
},
},
},
} {
tc := tc

View File

@ -245,6 +245,121 @@ func TestParseConfigEntry(t *testing.T) {
},
},
},
{
name: "terminating-gateway",
snake: `
kind = "terminating-gateway"
name = "terminating-gw-west"
namespace = "default"
services = [
{
name = "billing"
namespace = "biz"
ca_file = "/etc/ca.crt"
cert_file = "/etc/client.crt"
key_file = "/etc/tls.key"
},
{
name = "*"
namespace = "ops"
}
]
`,
camel: `
Kind = "terminating-gateway"
Name = "terminating-gw-west"
Namespace = "default"
Services = [
{
Name = "billing"
Namespace = "biz"
CAFile = "/etc/ca.crt"
CertFile = "/etc/client.crt"
KeyFile = "/etc/tls.key"
},
{
Name = "*"
Namespace = "ops"
}
]
`,
snakeJSON: `
{
"kind": "terminating-gateway",
"name": "terminating-gw-west",
"namespace": "default",
"services": [
{
"name": "billing",
"namespace": "biz",
"ca_file": "/etc/ca.crt",
"cert_file": "/etc/client.crt",
"key_file": "/etc/tls.key"
},
{
"name": "*",
"namespace": "ops"
}
]
}
`,
camelJSON: `
{
"Kind": "terminating-gateway",
"Name": "terminating-gw-west",
"Namespace": "default",
"Services": [
{
"Name": "billing",
"Namespace": "biz",
"CAFile": "/etc/ca.crt",
"CertFile": "/etc/client.crt",
"KeyFile": "/etc/tls.key"
},
{
"Name": "*",
"Namespace": "ops"
}
]
}
`,
expect: &api.TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Namespace: "default",
Services: []api.LinkedService{
{
Name: "billing",
Namespace: "biz",
CAFile: "/etc/ca.crt",
CertFile: "/etc/client.crt",
KeyFile: "/etc/tls.key",
},
{
Name: "*",
Namespace: "ops",
},
},
},
expectJSON: &api.TerminatingGatewayConfigEntry{
Kind: "terminating-gateway",
Name: "terminating-gw-west",
Namespace: "default",
Services: []api.LinkedService{
{
Name: "billing",
Namespace: "biz",
CAFile: "/etc/ca.crt",
CertFile: "/etc/client.crt",
KeyFile: "/etc/tls.key",
},
{
Name: "*",
Namespace: "ops",
},
},
},
},
{
name: "service-defaults",
snake: `
@ -1127,7 +1242,6 @@ func TestParseConfigEntry(t *testing.T) {
snake: `
kind = "ingress-gateway"
name = "ingress-web"
listeners = [
{
port = 8080