diff --git a/agent/consul/state/catalog.go b/agent/consul/state/catalog.go index 05db9a6e7..f30705c9f 100644 --- a/agent/consul/state/catalog.go +++ b/agent/consul/state/catalog.go @@ -2542,6 +2542,7 @@ func (s *Store) ingressConfigGatewayServices(tx *memdb.Txn, gateway structs.Serv Gateway: gateway, Service: service.ToServiceID(), GatewayKind: structs.ServiceKindIngressGateway, + Hosts: service.Hosts, Port: listener.Port, Protocol: listener.Protocol, } diff --git a/agent/consul/state/catalog_test.go b/agent/consul/state/catalog_test.go index 9f1e8e8fa..5150300c3 100644 --- a/agent/consul/state/catalog_test.go +++ b/agent/consul/state/catalog_test.go @@ -4997,6 +4997,7 @@ func TestStateStore_GatewayServices_Ingress(t *testing.T) { require.Len(results, 2) require.Equal("ingress1", results[0].Gateway.ID) require.Equal("service1", results[0].Service.ID) + require.Len(results[0].Hosts, 1) require.Equal(1111, results[0].Port) require.Equal("ingress1", results[1].Gateway.ID) require.Equal("service2", results[1].Service.ID) @@ -5217,7 +5218,8 @@ func setupIngressState(t *testing.T, s *Store) memdb.WatchSet { Protocol: "tcp", Services: []structs.IngressService{ { - Name: "service1", + Name: "service1", + Hosts: []string{"test.example.com"}, }, }, }, diff --git a/agent/proxycfg/state.go b/agent/proxycfg/state.go index 1fbe102c4..849171459 100644 --- a/agent/proxycfg/state.go +++ b/agent/proxycfg/state.go @@ -1355,6 +1355,7 @@ func makeUpstream(g *structs.GatewayService, bindAddr string) structs.Upstream { DestinationName: g.Service.ID, DestinationNamespace: g.Service.NamespaceOrDefault(), LocalBindPort: g.Port, + IngressHosts: g.Hosts, // Pass the protocol that was configured on the ingress listener in order // to force that protocol on the Envoy listener. Config: map[string]interface{}{ diff --git a/agent/structs/config_entry_gateways.go b/agent/structs/config_entry_gateways.go index 6c1563c5f..996dac18d 100644 --- a/agent/structs/config_entry_gateways.go +++ b/agent/structs/config_entry_gateways.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/hashicorp/consul/acl" + "github.com/hashicorp/consul/lib" ) // IngressGatewayConfigEntry manages the configuration for an ingress service @@ -51,6 +52,15 @@ type IngressService struct { // protocol and means that the listener will forward traffic to all services. Name string + // Hosts is a list of hostnames which should be associated to this service on + // the defined listener. Only allowed on layer 7 protocols, this will be used + // to route traffic to the service by matching the Host header of the HTTP + // request. + // + // This cannot be specified when using the wildcard specifier, "*", or when + // using a "tcp" listener. + Hosts []string + EnterpriseMeta `hcl:",squash" mapstructure:",squash"` } @@ -109,18 +119,6 @@ func (e *IngressGatewayConfigEntry) Validate() error { return fmt.Errorf("Protocol must be either 'http' or 'tcp', '%s' is an unsupported protocol.", listener.Protocol) } - for _, s := range listener.Services { - if s.Name == WildcardSpecifier && listener.Protocol != "http" { - return fmt.Errorf("Wildcard service name is only valid for protocol = 'http' (listener on port %d)", listener.Port) - } - if s.Name == "" { - return fmt.Errorf("Service name cannot be blank (listener on port %d)", listener.Port) - } - if s.NamespaceOrDefault() == WildcardSpecifier { - return fmt.Errorf("Wildcard namespace is not supported for ingress services (listener on port %d)", listener.Port) - } - } - if len(listener.Services) == 0 { return fmt.Errorf("No service declared for listener with port %d", listener.Port) } @@ -130,6 +128,35 @@ func (e *IngressGatewayConfigEntry) Validate() error { return fmt.Errorf("Multiple services per listener are only supported for protocol = 'http' (listener on port %d)", listener.Port) } + + declaredHosts := make(map[string]bool) + for _, s := range listener.Services { + if listener.Protocol == "tcp" { + if s.Name == WildcardSpecifier { + return fmt.Errorf("Wildcard service name is only valid for protocol = 'http' (listener on port %d)", listener.Port) + } + if len(s.Hosts) != 0 { + return fmt.Errorf("Associating hosts to a service is not supported for the %s protocol (listener on port %d)", listener.Protocol, listener.Port) + } + } + if s.Name == "" { + return fmt.Errorf("Service name cannot be blank (listener on port %d)", listener.Port) + } + if s.Name == WildcardSpecifier && len(s.Hosts) != 0 { + return fmt.Errorf("Associating hosts to a wildcard service is not supported (listener on port %d)", listener.Port) + } + if s.NamespaceOrDefault() == WildcardSpecifier { + return fmt.Errorf("Wildcard namespace is not supported for ingress services (listener on port %d)", listener.Port) + } + + // TODO(ingress): Validate Hosts are valid? + for _, h := range s.Hosts { + if declaredHosts[h] { + return fmt.Errorf("Hosts must be unique within a specific listener (listener on port %d)", listener.Port) + } + declaredHosts[h] = true + } + } } return nil @@ -296,6 +323,7 @@ type GatewayService struct { GatewayKind ServiceKind Port int Protocol string + Hosts []string CAFile string CertFile string KeyFile string @@ -312,6 +340,7 @@ func (g *GatewayService) IsSame(o *GatewayService) bool { g.GatewayKind == o.GatewayKind && g.Port == o.Port && g.Protocol == o.Protocol && + lib.StringSliceEqual(g.Hosts, o.Hosts) && g.CAFile == o.CAFile && g.CertFile == o.CertFile && g.KeyFile == o.KeyFile && @@ -321,11 +350,13 @@ func (g *GatewayService) IsSame(o *GatewayService) bool { func (g *GatewayService) Clone() *GatewayService { return &GatewayService{ - Gateway: g.Gateway, - Service: g.Service, - GatewayKind: g.GatewayKind, - Port: g.Port, - Protocol: g.Protocol, + Gateway: g.Gateway, + Service: g.Service, + GatewayKind: g.GatewayKind, + Port: g.Port, + Protocol: g.Protocol, + // See https://github.com/go101/go101/wiki/How-to-efficiently-clone-a-slice%3F + Hosts: append(g.Hosts[:0:0], g.Hosts...), CAFile: g.CAFile, CertFile: g.CertFile, KeyFile: g.KeyFile, diff --git a/agent/structs/config_entry_gateways_test.go b/agent/structs/config_entry_gateways_test.go index 01c81f5de..e00ae0a2d 100644 --- a/agent/structs/config_entry_gateways_test.go +++ b/agent/structs/config_entry_gateways_test.go @@ -252,6 +252,70 @@ func TestIngressConfigEntry_Validate(t *testing.T) { }, expectErr: "Protocol must be either 'http' or 'tcp', 'asdf' is an unsupported protocol.", }, + { + name: "hosts cannot be set on a tcp listener", + entry: IngressGatewayConfigEntry{ + Kind: "ingress-gateway", + Name: "ingress-web", + Listeners: []IngressListener{ + { + Port: 1111, + Protocol: "tcp", + Services: []IngressService{ + { + Name: "db", + Hosts: []string{"db.example.com"}, + }, + }, + }, + }, + }, + expectErr: "Associating hosts to a service is not supported for the tcp protocol", + }, + { + name: "hosts cannot be set on a wildcard specifier", + entry: IngressGatewayConfigEntry{ + Kind: "ingress-gateway", + Name: "ingress-web", + Listeners: []IngressListener{ + { + Port: 1111, + Protocol: "http", + Services: []IngressService{ + { + Name: "*", + Hosts: []string{"db.example.com"}, + }, + }, + }, + }, + }, + expectErr: "Associating hosts to a wildcard service is not supported", + }, + { + name: "hosts must be unique per listener", + entry: IngressGatewayConfigEntry{ + Kind: "ingress-gateway", + Name: "ingress-web", + Listeners: []IngressListener{ + { + Port: 1111, + Protocol: "http", + Services: []IngressService{ + { + Name: "db", + Hosts: []string{"test.example.com"}, + }, + { + Name: "api", + Hosts: []string{"test.example.com"}, + }, + }, + }, + }, + }, + expectErr: "Hosts must be unique within a specific listener", + }, } for _, test := range cases { diff --git a/agent/structs/config_entry_test.go b/agent/structs/config_entry_test.go index a0eec8e5f..6e4f84131 100644 --- a/agent/structs/config_entry_test.go +++ b/agent/structs/config_entry_test.go @@ -545,6 +545,7 @@ func TestDecodeConfigEntry(t *testing.T) { services = [ { name = "web" + hosts = ["test.example.com", "test2.example.com"] }, { name = "db" @@ -581,6 +582,7 @@ func TestDecodeConfigEntry(t *testing.T) { Services = [ { Name = "web" + Hosts = ["test.example.com", "test2.example.com"] }, { Name = "db" @@ -616,7 +618,8 @@ func TestDecodeConfigEntry(t *testing.T) { Protocol: "http", Services: []IngressService{ IngressService{ - Name: "web", + Name: "web", + Hosts: []string{"test.example.com", "test2.example.com"}, }, IngressService{ Name: "db", diff --git a/agent/structs/connect_proxy_config.go b/agent/structs/connect_proxy_config.go index 8344225f4..a2e32c1c7 100644 --- a/agent/structs/connect_proxy_config.go +++ b/agent/structs/connect_proxy_config.go @@ -247,6 +247,10 @@ type Upstream struct { // MeshGateway is the configuration for mesh gateway usage of this upstream MeshGateway MeshGatewayConfig `json:",omitempty"` + + // IngressHosts are a list of hosts that should route to this upstream from + // an ingress gateway + IngressHosts []string `json:"-" bexpr:"-"` } func (t *Upstream) UnmarshalJSON(data []byte) (err error) { diff --git a/agent/xds/routes.go b/agent/xds/routes.go index 5036d2a82..a53eebe24 100644 --- a/agent/xds/routes.go +++ b/agent/xds/routes.go @@ -49,14 +49,14 @@ func routesFromSnapshotConnectProxy(cfgSnap *proxycfg.ConfigSnapshot) ([]proto.M if chain == nil || chain.IsDefault() { // TODO(rb): make this do the old school stuff too } else { - virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, "*") + virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, []string{"*"}) if err != nil { return nil, err } route := &envoy.RouteConfiguration{ Name: upstreamID, - VirtualHosts: []envoyroute.VirtualHost{*virtualHost}, + VirtualHosts: []envoyroute.VirtualHost{virtualHost}, ValidateClusters: makeBoolValue(true), } resources = append(resources, route) @@ -91,21 +91,31 @@ func routesFromSnapshotIngressGateway(cfgSnap *proxycfg.ConfigSnapshot) ([]proto for _, u := range upstreams { upstreamID := u.Identifier() chain := cfgSnap.IngressGateway.DiscoveryChain[upstreamID] - if chain != nil { - domain := fmt.Sprintf("%s.*", chain.ServiceName) + if chain == nil { + continue + } + + var domains []string + switch { + case len(upstreams) == 1: // Don't require a service prefix on the domain if there is only 1 // upstream. This makes it a smoother experience when only having a // single service associated to a listener, which is probably a common // case when demoing/testing - if len(upstreams) == 1 { - domain = "*" - } - virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, domain) - if err != nil { - return nil, err - } - upstreamRoute.VirtualHosts = append(upstreamRoute.VirtualHosts, *virtualHost) + domains = []string{"*"} + case len(u.IngressHosts) > 0: + // If a user has specified hosts, do not add the default + // ".*" prefix + domains = u.IngressHosts + default: + domains = []string{fmt.Sprintf("%s.*", chain.ServiceName)} } + + virtualHost, err := makeUpstreamRouteForDiscoveryChain(upstreamID, chain, domains) + if err != nil { + return nil, err + } + upstreamRoute.VirtualHosts = append(upstreamRoute.VirtualHosts, virtualHost) } result = append(result, upstreamRoute) @@ -117,8 +127,8 @@ func routesFromSnapshotIngressGateway(cfgSnap *proxycfg.ConfigSnapshot) ([]proto func makeUpstreamRouteForDiscoveryChain( routeName string, chain *structs.CompiledDiscoveryChain, - serviceDomain string, -) (*envoyroute.VirtualHost, error) { + serviceDomains []string, +) (envoyroute.VirtualHost, error) { var routes []envoyroute.Route startNode := chain.Nodes[chain.StartNode] @@ -143,14 +153,14 @@ func makeUpstreamRouteForDiscoveryChain( case structs.DiscoveryGraphNodeTypeSplitter: routeAction, err = makeRouteActionForSplitter(nextNode.Splits, chain) if err != nil { - return nil, err + return envoyroute.VirtualHost{}, err } case structs.DiscoveryGraphNodeTypeResolver: routeAction = makeRouteActionForSingleCluster(nextNode.Resolver.Target, chain) default: - return nil, fmt.Errorf("unexpected graph node after route %q", nextNode.Type) + return envoyroute.VirtualHost{}, fmt.Errorf("unexpected graph node after route %q", nextNode.Type) } // TODO(rb): Better help handle the envoy case where you need (prefix=/foo/,rewrite=/) and (exact=/foo,rewrite=/) to do a full rewrite @@ -197,7 +207,7 @@ func makeUpstreamRouteForDiscoveryChain( case structs.DiscoveryGraphNodeTypeSplitter: routeAction, err := makeRouteActionForSplitter(startNode.Splits, chain) if err != nil { - return nil, err + return envoyroute.VirtualHost{}, err } defaultRoute := envoyroute.Route{ @@ -221,9 +231,9 @@ func makeUpstreamRouteForDiscoveryChain( panic("unknown first node in discovery chain of type: " + startNode.Type) } - host := &envoyroute.VirtualHost{ + host := envoyroute.VirtualHost{ Name: routeName, - Domains: []string{serviceDomain}, + Domains: serviceDomains, Routes: routes, } diff --git a/agent/xds/routes_test.go b/agent/xds/routes_test.go index c0c0fa7a2..d6614025c 100644 --- a/agent/xds/routes_test.go +++ b/agent/xds/routes_test.go @@ -117,6 +117,7 @@ func TestRoutesFromSnapshot(t *testing.T) { { DestinationName: "foo", LocalBindPort: 8080, + IngressHosts: []string{"test1.example.com", "test2.example.com"}, }, { DestinationName: "bar", diff --git a/agent/xds/testdata/routes/ingress-http-multiple-services.golden b/agent/xds/testdata/routes/ingress-http-multiple-services.golden index d458dc110..8d6e2e088 100644 --- a/agent/xds/testdata/routes/ingress-http-multiple-services.golden +++ b/agent/xds/testdata/routes/ingress-http-multiple-services.golden @@ -47,7 +47,8 @@ { "name": "foo", "domains": [ - "foo.*" + "test1.example.com", + "test2.example.com" ], "routes": [ { diff --git a/api/config_entry_gateways.go b/api/config_entry_gateways.go index f6babe205..add483db0 100644 --- a/api/config_entry_gateways.go +++ b/api/config_entry_gateways.go @@ -57,6 +57,15 @@ type IngressService struct { // protocol and means that the listener will forward traffic to all services. Name string + // Hosts is a list of hostnames which should be associated to this service on + // the defined listener. Only allowed on layer 7 protocols, this will be used + // to route traffic to the service by matching the Host header of the HTTP + // request. + // + // This cannot be specified when using the wildcard specifier, "*", or when + // using a "tcp" listener. + Hosts []string + // Namespace is the namespace where the service is located. // Namespacing is a Consul Enterprise feature. Namespace string `json:",omitempty"` diff --git a/api/config_entry_gateways_test.go b/api/config_entry_gateways_test.go index 5f730a15b..d8bf50c93 100644 --- a/api/config_entry_gateways_test.go +++ b/api/config_entry_gateways_test.go @@ -51,10 +51,11 @@ func TestAPI_ConfigEntries_IngressGateway(t *testing.T) { ingress1.Listeners = []IngressListener{ { Port: 2222, - Protocol: "tcp", + Protocol: "http", Services: []IngressService{ { - Name: "asdf", + Name: "asdf", + Hosts: []string{"test.example.com"}, }, }, }, diff --git a/command/config/write/config_write_test.go b/command/config/write/config_write_test.go index 01f9b360e..db4972e52 100644 --- a/command/config/write/config_write_test.go +++ b/command/config/write/config_write_test.go @@ -1395,6 +1395,7 @@ func TestParseConfigEntry(t *testing.T) { services = [ { name = "web" + hosts = ["test.example.com"] }, { name = "db" @@ -1414,6 +1415,7 @@ func TestParseConfigEntry(t *testing.T) { Services = [ { Name = "web" + Hosts = ["test.example.com"] }, { Name = "db" @@ -1433,7 +1435,8 @@ func TestParseConfigEntry(t *testing.T) { "protocol": "http", "services": [ { - "name": "web" + "name": "web", + "hosts": ["test.example.com"] }, { "name": "db", @@ -1454,7 +1457,8 @@ func TestParseConfigEntry(t *testing.T) { "Protocol": "http", "Services": [ { - "Name": "web" + "Name": "web", + "Hosts": ["test.example.com"] }, { "Name": "db", @@ -1474,7 +1478,8 @@ func TestParseConfigEntry(t *testing.T) { Protocol: "http", Services: []api.IngressService{ { - Name: "web", + Name: "web", + Hosts: []string{"test.example.com"}, }, { Name: "db", diff --git a/test/integration/connect/envoy/case-ingress-gateway-http/verify.bats b/test/integration/connect/envoy/case-ingress-gateway-http/verify.bats index e67da57d8..4e3e08633 100644 --- a/test/integration/connect/envoy/case-ingress-gateway-http/verify.bats +++ b/test/integration/connect/envoy/case-ingress-gateway-http/verify.bats @@ -31,28 +31,10 @@ load helpers } @test "ingress should be able to connect to s1 via configured path" { - run retry_default curl -s -f localhost:9999/s1/debug?env=dump - [ "$status" -eq 0 ] - - GOT=$(echo "$output" | grep -E "^FORTIO_NAME=") - EXPECT_NAME="s1" - - if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then - echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2 - return 1 - fi + assert_expected_fortio_name s1 localhost 9999 /s1 } @test "ingress should be able to connect to s2 via configured path" { - run retry_default curl -s -f localhost:9999/s2/debug?env=dump - [ "$status" -eq 0 ] - - GOT=$(echo "$output" | grep -E "^FORTIO_NAME=") - EXPECT_NAME="s2" - - if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then - echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2 - return 1 - fi + assert_expected_fortio_name s2 localhost 9999 /s2 } diff --git a/test/integration/connect/envoy/case-ingress-gateway-multiple-services/config_entries.hcl b/test/integration/connect/envoy/case-ingress-gateway-multiple-services/config_entries.hcl index b99168d2b..f799a85a0 100644 --- a/test/integration/connect/envoy/case-ingress-gateway-multiple-services/config_entries.hcl +++ b/test/integration/connect/envoy/case-ingress-gateway-multiple-services/config_entries.hcl @@ -15,6 +15,16 @@ config_entries { name = "*" } ] + }, + { + port = 9998 + protocol = "http" + services = [ + { + name = "s1" + hosts = ["test.example.com"] + } + ] } ] }, diff --git a/test/integration/connect/envoy/case-ingress-gateway-multiple-services/verify.bats b/test/integration/connect/envoy/case-ingress-gateway-multiple-services/verify.bats index 26a642700..e7e8389f0 100644 --- a/test/integration/connect/envoy/case-ingress-gateway-multiple-services/verify.bats +++ b/test/integration/connect/envoy/case-ingress-gateway-multiple-services/verify.bats @@ -31,28 +31,13 @@ load helpers } @test "ingress should be able to connect to s1 using Host header" { - run retry_default curl -H"Host: s1.example.consul" -s -f localhost:9999/debug?env=dump - [ "$status" -eq 0 ] - - GOT=$(echo "$output" | grep -E "^FORTIO_NAME=") - EXPECT_NAME="s1" - - if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then - echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2 - return 1 - fi + assert_expected_fortio_name s1 s1.example.consul 9999 } @test "ingress should be able to connect to s2 using Host header" { - run retry_default curl -H"Host: s2.example.consul" -s -f localhost:9999/debug?env=dump - [ "$status" -eq 0 ] - - GOT=$(echo "$output" | grep -E "^FORTIO_NAME=") - EXPECT_NAME="s2" - - if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then - echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2 - return 1 - fi + assert_expected_fortio_name s2 s2.example.consul 9999 } +@test "ingress should be able to connect to s1 using a user-specified Host" { + assert_expected_fortio_name s1 test.example.com 9998 +} diff --git a/test/integration/connect/envoy/helpers.bash b/test/integration/connect/envoy/helpers.bash index 9a4c97f16..94f4ca59f 100755 --- a/test/integration/connect/envoy/helpers.bash +++ b/test/integration/connect/envoy/helpers.bash @@ -646,15 +646,21 @@ function set_ttl_check_state { } function get_upstream_fortio_name { - run retry_default curl -v -s -f localhost:5000/debug?env=dump + local HOST=$1 + local PORT=$2 + local PREFIX=$3 + run retry_default curl -v -s -f -H"Host: ${HOST}" "localhost:${PORT}${PREFIX}/debug?env=dump" [ "$status" == 0 ] echo "$output" | grep -E "^FORTIO_NAME=" } function assert_expected_fortio_name { local EXPECT_NAME=$1 + local HOST=${2:-"localhost"} + local PORT=${3:-5000} + local URL_PREFIX=${4:-""} - GOT=$(get_upstream_fortio_name) + GOT=$(get_upstream_fortio_name ${HOST} ${PORT} ${URL_PREFIX}) if [ "$GOT" != "FORTIO_NAME=${EXPECT_NAME}" ]; then echo "expected name: $EXPECT_NAME, actual name: $GOT" 1>&2