diff --git a/.changelog/10613.txt b/.changelog/10613.txt new file mode 100644 index 000000000..6499f35ae --- /dev/null +++ b/.changelog/10613.txt @@ -0,0 +1,3 @@ +```release-note:improvement +connect: Support manipulating HTTP headers in the mesh. +``` \ No newline at end of file diff --git a/agent/consul/discoverychain/compile.go b/agent/consul/discoverychain/compile.go index bd385abdf..758af0da2 100644 --- a/agent/consul/discoverychain/compile.go +++ b/agent/consul/discoverychain/compile.go @@ -274,7 +274,9 @@ func (c *compiler) compile() (*structs.CompiledDiscoveryChain, error) { return nil, err } - c.flattenAdjacentSplitterNodes() + if err := c.flattenAdjacentSplitterNodes(); err != nil { + return nil, err + } if err := c.removeUnusedNodes(); err != nil { return nil, err @@ -394,7 +396,7 @@ func (c *compiler) detectCircularReferences() error { return nil } -func (c *compiler) flattenAdjacentSplitterNodes() { +func (c *compiler) flattenAdjacentSplitterNodes() error { for { anyChanged := false for _, node := range c.nodes { @@ -416,9 +418,16 @@ func (c *compiler) flattenAdjacentSplitterNodes() { for _, innerSplit := range nextNode.Splits { effectiveWeight := split.Weight * innerSplit.Weight / 100 + // Copy the definition from the inner node but merge in the parent + // to preserve any config it needs to pass through. + newDef, err := innerSplit.Definition.MergeParent(split.Definition) + if err != nil { + return err + } newDiscoverySplit := &structs.DiscoverySplit{ - Weight: structs.NormalizeServiceSplitWeight(effectiveWeight), - NextNode: innerSplit.NextNode, + Definition: newDef, + Weight: structs.NormalizeServiceSplitWeight(effectiveWeight), + NextNode: innerSplit.NextNode, } fixedSplits = append(fixedSplits, newDiscoverySplit) @@ -432,7 +441,7 @@ func (c *compiler) flattenAdjacentSplitterNodes() { } if !anyChanged { - return + return nil } } } @@ -723,9 +732,16 @@ func (c *compiler) getSplitterNode(sid structs.ServiceID) (*structs.DiscoveryGra c.recordNode(splitNode) var hasLB bool - for _, split := range splitter.Splits { + for i := range splitter.Splits { + // We don't use range variables here because we'll take the address of + // this split and store that in a DiscoveryGraphNode and the range + // variables share memory addresses between iterations which is exactly + // wrong for us here. + split := splitter.Splits[i] + compiledSplit := &structs.DiscoverySplit{ - Weight: split.Weight, + Definition: &split, + Weight: split.Weight, } splitNode.Splits = append(splitNode.Splits, compiledSplit) diff --git a/agent/consul/discoverychain/compile_test.go b/agent/consul/discoverychain/compile_test.go index bb26415c8..54c91e2ea 100644 --- a/agent/consul/discoverychain/compile_test.go +++ b/agent/consul/discoverychain/compile_test.go @@ -339,6 +339,9 @@ func testcase_RouterWithDefaults_WithNoopSplit_DefaultResolver() compileTestCase Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 100, + }, Weight: 100, NextNode: "resolver:main.default.default.dc1", }, @@ -401,6 +404,9 @@ func testcase_NoopSplit_DefaultResolver_ProtocolFromProxyDefaults() compileTestC Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 100, + }, Weight: 100, NextNode: "resolver:main.default.default.dc1", }, @@ -470,6 +476,9 @@ func testcase_RouterWithDefaults_WithNoopSplit_WithResolver() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 100, + }, Weight: 100, NextNode: "resolver:main.default.default.dc1", }, @@ -604,6 +613,9 @@ func testcase_NoopSplit_DefaultResolver() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 100, + }, Weight: 100, NextNode: "resolver:main.default.default.dc1", }, @@ -657,6 +669,9 @@ func testcase_NoopSplit_WithResolver() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 100, + }, Weight: 100, NextNode: "resolver:main.default.default.dc1", }, @@ -717,10 +732,18 @@ func testcase_SubsetSplit() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 60, + ServiceSubset: "v2", + }, Weight: 60, NextNode: "resolver:v2.main.default.default.dc1", }, { + Definition: &structs.ServiceSplit{ + Weight: 40, + ServiceSubset: "v1", + }, Weight: 40, NextNode: "resolver:v1.main.default.default.dc1", }, @@ -786,10 +809,18 @@ func testcase_ServiceSplit() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 60, + Service: "foo", + }, Weight: 60, NextNode: "resolver:foo.default.default.dc1", }, { + Definition: &structs.ServiceSplit{ + Weight: 40, + Service: "bar", + }, Weight: 40, NextNode: "resolver:bar.default.default.dc1", }, @@ -875,6 +906,11 @@ func testcase_SplitBypassesSplit() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 100, + Service: "next", + ServiceSubset: "bypassed", + }, Weight: 100, NextNode: "resolver:bypassed.next.default.default.dc1", }, @@ -1352,6 +1388,9 @@ func testcase_NoopSplit_WithDefaultSubset() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 100, + }, Weight: 100, NextNode: "resolver:v2.main.default.default.dc1", }, @@ -1660,10 +1699,18 @@ func testcase_MultiDatacenterCanary() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 60, + Service: "main-dc2", + }, Weight: 60, NextNode: "resolver:main.default.default.dc2", }, { + Definition: &structs.ServiceSplit{ + Weight: 40, + Service: "main-dc3", + }, Weight: 40, NextNode: "resolver:main.default.default.dc3", }, @@ -1728,7 +1775,22 @@ func testcase_AllBellsAndWhistles() compileTestCase { Name: "svc-split-again", Splits: []structs.ServiceSplit{ {Weight: 75, Service: "main", ServiceSubset: "v1"}, - {Weight: 25, Service: "svc-split-one-more-time"}, + { + Weight: 25, + Service: "svc-split-one-more-time", + RequestHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "parent": "1", + "shared": "from-parent", + }, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "parent": "2", + "shared": "from-parent", + }, + }, + }, }, }, &structs.ServiceSplitterConfigEntry{ @@ -1736,7 +1798,23 @@ func testcase_AllBellsAndWhistles() compileTestCase { Name: "svc-split-one-more-time", Splits: []structs.ServiceSplit{ {Weight: 80, Service: "main", ServiceSubset: "v2"}, - {Weight: 20, Service: "main", ServiceSubset: "v3"}, + { + Weight: 20, + Service: "main", + ServiceSubset: "v3", + RequestHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "child": "3", + "shared": "from-child", + }, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "child": "4", + "shared": "from-parent", + }, + }, + }, }, }, ) @@ -1820,18 +1898,66 @@ func testcase_AllBellsAndWhistles() compileTestCase { Name: "svc-split.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 60, + Service: "svc-redirect", + }, Weight: 60, NextNode: "resolver:prod.redirected.default.default.dc1", }, { + Definition: &structs.ServiceSplit{ + Weight: 75, + Service: "main", + ServiceSubset: "v1", + }, Weight: 30, NextNode: "resolver:v1.main.default.default.dc1", }, { + Definition: &structs.ServiceSplit{ + Weight: 80, + Service: "main", + ServiceSubset: "v2", + // Should inherit these from parent verbatim as there was no + // child-split header manip. + RequestHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "parent": "1", + "shared": "from-parent", + }, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "parent": "2", + "shared": "from-parent", + }, + }, + }, Weight: 8, NextNode: "resolver:v2.main.default.default.dc1", }, { + Definition: &structs.ServiceSplit{ + Weight: 20, + Service: "main", + ServiceSubset: "v3", + // Should get a merge of child and parent rules + RequestHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "parent": "1", + "child": "3", + "shared": "from-child", + }, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{ + "parent": "2", + "child": "4", + "shared": "from-parent", + }, + }, + }, Weight: 2, NextNode: "resolver:v3.main.default.default.dc1", }, @@ -2329,14 +2455,26 @@ func testcase_LBSplitterAndResolver() compileTestCase { Name: "main.default", Splits: []*structs.DiscoverySplit{ { + Definition: &structs.ServiceSplit{ + Weight: 60, + Service: "foo", + }, Weight: 60, NextNode: "resolver:foo.default.default.dc1", }, { + Definition: &structs.ServiceSplit{ + Weight: 20, + Service: "bar", + }, Weight: 20, NextNode: "resolver:bar.default.default.dc1", }, { + Definition: &structs.ServiceSplit{ + Weight: 20, + Service: "baz", + }, Weight: 20, NextNode: "resolver:baz.default.default.dc1", }, diff --git a/agent/proxycfg/ingress_gateway.go b/agent/proxycfg/ingress_gateway.go index 14fc0b638..3ee2c205c 100644 --- a/agent/proxycfg/ingress_gateway.go +++ b/agent/proxycfg/ingress_gateway.go @@ -54,6 +54,7 @@ func (s *handlerIngressGateway) initialize(ctx context.Context) (ConfigSnapshot, snap.IngressGateway.WatchedUpstreamEndpoints = make(map[string]map[string]structs.CheckServiceNodes) snap.IngressGateway.WatchedGateways = make(map[string]map[string]context.CancelFunc) snap.IngressGateway.WatchedGatewayEndpoints = make(map[string]map[string]structs.CheckServiceNodes) + snap.IngressGateway.Listeners = make(map[IngressListenerKey]structs.IngressListener) return snap, nil } @@ -82,6 +83,13 @@ func (s *handlerIngressGateway) handleUpdate(ctx context.Context, u cache.Update snap.IngressGateway.TLSEnabled = gatewayConf.TLS.Enabled snap.IngressGateway.TLSSet = true + // Load each listener's config from the config entry so we don't have to + // pass listener config through "upstreams" types as that grows. + for _, l := range gatewayConf.Listeners { + key := IngressListenerKey{Protocol: l.Protocol, Port: l.Port} + snap.IngressGateway.Listeners[key] = l + } + if err := s.watchIngressLeafCert(ctx, snap); err != nil { return err } diff --git a/agent/proxycfg/snapshot.go b/agent/proxycfg/snapshot.go index 87e7326e8..171c04ae8 100644 --- a/agent/proxycfg/snapshot.go +++ b/agent/proxycfg/snapshot.go @@ -321,6 +321,10 @@ type configSnapshotIngressGateway struct { // to. This is constructed from the ingress-gateway config entry, and uses // the GatewayServices RPC to retrieve them. Upstreams map[IngressListenerKey]structs.Upstreams + + // Listeners is the original listener config from the ingress-gateway config + // entry to save us trying to pass fields through Upstreams + Listeners map[IngressListenerKey]structs.IngressListener } func (c *configSnapshotIngressGateway) IsEmpty() bool { diff --git a/agent/proxycfg/testing.go b/agent/proxycfg/testing.go index b55deaf6d..e035bd04d 100644 --- a/agent/proxycfg/testing.go +++ b/agent/proxycfg/testing.go @@ -1040,9 +1040,36 @@ func setupTestVariationConfigEntriesAndSnapshot( Kind: structs.ServiceSplitter, Name: "db", Splits: []structs.ServiceSplit{ - {Weight: 95.5, Service: "big-side"}, - {Weight: 4, Service: "goldilocks-side"}, - {Weight: 0.5, Service: "lil-bit-side"}, + { + Weight: 95.5, + Service: "big-side", + RequestHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{"x-split-leg": "big"}, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{"x-split-leg": "big"}, + }, + }, + { + Weight: 4, + Service: "goldilocks-side", + RequestHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{"x-split-leg": "goldilocks"}, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{"x-split-leg": "goldilocks"}, + }, + }, + { + Weight: 0.5, + Service: "lil-bit-side", + RequestHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{"x-split-leg": "small"}, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Set: map[string]string{"x-split-leg": "small"}, + }, + }, }, }, ) @@ -1281,6 +1308,32 @@ func setupTestVariationConfigEntriesAndSnapshot( }), Destination: toService("split-3-ways"), }, + { + Match: httpMatch(&structs.ServiceRouteHTTPMatch{ + PathExact: "/header-manip", + }), + Destination: &structs.ServiceRouteDestination{ + Service: "header-manip", + RequestHeaders: &structs.HTTPHeaderModifiers{ + Add: map[string]string{ + "request": "bar", + }, + Set: map[string]string{ + "bar": "baz", + }, + Remove: []string{"qux"}, + }, + ResponseHeaders: &structs.HTTPHeaderModifiers{ + Add: map[string]string{ + "response": "bar", + }, + Set: map[string]string{ + "bar": "baz", + }, + Remove: []string{"qux"}, + }, + }, + }, }, }, ) @@ -1681,6 +1734,15 @@ func testConfigSnapshotIngressGateway( }, }, }, + Listeners: map[IngressListenerKey]structs.IngressListener{ + {protocol, 9191}: { + Port: 9191, + Protocol: protocol, + Services: []structs.IngressService{ + {Name: "db"}, + }, + }, + }, } } return snap diff --git a/agent/structs/config_entry_discoverychain.go b/agent/structs/config_entry_discoverychain.go index cfe562a92..0a8567d67 100644 --- a/agent/structs/config_entry_discoverychain.go +++ b/agent/structs/config_entry_discoverychain.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/mitchellh/copystructure" "github.com/mitchellh/hashstructure" "github.com/hashicorp/consul/acl" @@ -400,6 +401,10 @@ type ServiceRouteDestination struct { // RetryOnStatusCodes is a flat list of http response status codes that are // eligible for retry. This again should be feasible in any reasonable proxy. RetryOnStatusCodes []uint32 `json:",omitempty" alias:"retry_on_status_codes"` + + // Allow HTTP header manipulation to be configured. + RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` + ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` } func (e *ServiceRouteDestination) MarshalJSON() ([]byte, error) { @@ -658,6 +663,83 @@ type ServiceSplit struct { // If this field is specified then this route is ineligible for further // splitting. Namespace string `json:",omitempty"` + + // NOTE: Partition is not represented here by design. Do not add it. + + // NOTE: Any configuration added to Splits that needs to be passed to the + // proxy needs special handling MergeParent below. + + // Allow HTTP header manipulation to be configured. + RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` + ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` +} + +// MergeParent is called by the discovery chain compiler when a split directs to +// another splitter. We refer to the first ServiceSplit as the parent and the +// ServiceSplits of the second splitter as its children. The parent ends up +// "flattened" by the compiler, i.e. replaced with its children recursively with +// the weights modified as necessary. +// +// Since the parent is never included in the output, any request processing +// config attached to it (e.g. header manipulation) would be lost and not take +// affect when splitters direct to other splitters. To avoid that, we define a +// MergeParent operation which is called by the compiler on each child split +// during flattening. It must merge any request processing configuration from +// the passed parent into the child such that the end result is equivalent to a +// request first passing through the parent and then the child. Response +// handling must occur as if the request first passed through the through the +// child to the parent. +// +// MergeDefaults leaves both s and parent unchanged and returns a deep copy to +// avoid confusing issues where config changes after being compiled. +func (s *ServiceSplit) MergeParent(parent *ServiceSplit) (*ServiceSplit, error) { + if s == nil && parent == nil { + return nil, nil + } + + var err error + var copy ServiceSplit + + if s == nil { + copy = *parent + copy.RequestHeaders, err = parent.RequestHeaders.Clone() + if err != nil { + return nil, err + } + copy.ResponseHeaders, err = parent.ResponseHeaders.Clone() + if err != nil { + return nil, err + } + return ©, nil + } else { + copy = *s + } + + var parentReq *HTTPHeaderModifiers + if parent != nil { + parentReq = parent.RequestHeaders + } + + // Merge any request handling from parent _unless_ it's overridden by us. + copy.RequestHeaders, err = MergeHTTPHeaderModifiers(parentReq, s.RequestHeaders) + if err != nil { + return nil, err + } + + var parentResp *HTTPHeaderModifiers + if parent != nil { + parentResp = parent.ResponseHeaders + } + + // Merge any response handling. Note that we allow parent to override this + // time since responses flow the other way so the unflattened behavior would + // be that the parent processing happens _after_ ours potentially overriding + // it. + copy.ResponseHeaders, err = MergeHTTPHeaderModifiers(s.ResponseHeaders, parentResp) + if err != nil { + return nil, err + } + return ©, nil } // ServiceResolverConfigEntry defines which instances of a service should @@ -1461,3 +1543,94 @@ func IsProtocolHTTPLike(protocol string) bool { return false } } + +// HTTPHeaderModifiers is a set of rules for HTTP header modification that +// should be performed by proxies as the request passes through them. It can +// operate on either request or response headers depending on the context in +// which it is used. +type HTTPHeaderModifiers struct { + // Add is a set of name -> value pairs that should be appended to the request + // or response (i.e. allowing duplicates if the same header already exists). + Add map[string]string `json:",omitempty"` + + // Set is a set of name -> value pairs that should be added to the request or + // response, overwriting any existing header values of the same name. + Set map[string]string `json:",omitempty"` + + // Remove is the set of header names that should be stripped from the request + // or response. + Remove []string `json:",omitempty"` +} + +func (m *HTTPHeaderModifiers) IsZero() bool { + if m == nil { + return true + } + return len(m.Add) == 0 && len(m.Set) == 0 && len(m.Remove) == 0 +} + +func (m *HTTPHeaderModifiers) Validate(protocol string) error { + if m.IsZero() { + return nil + } + if !IsProtocolHTTPLike(protocol) { + // Non nil but context is not an httpish protocol + return fmt.Errorf("only valid for http, http2 and grpc protocols") + } + return nil +} + +// Clone returns a deep-copy of m unless m is nil +func (m *HTTPHeaderModifiers) Clone() (*HTTPHeaderModifiers, error) { + if m == nil { + return nil, nil + } + + cpy, err := copystructure.Copy(m) + if err != nil { + return nil, err + } + m = cpy.(*HTTPHeaderModifiers) + return m, nil +} + +// MergeHTTPHeaderModifiers takes a base HTTPHeaderModifiers and merges in field +// defined in overrides. Precedence is given to the overrides field if there is +// a collision. The resulting object is returned leaving both base and overrides +// unchanged. The `Add` field in override also replaces same-named keys of base +// since we have no way to express multiple adds to the same key. We could +// change that, but it makes the config syntax more complex for a huge edgecase. +func MergeHTTPHeaderModifiers(base, overrides *HTTPHeaderModifiers) (*HTTPHeaderModifiers, error) { + if base.IsZero() { + return overrides.Clone() + } + + merged, err := base.Clone() + if err != nil { + return nil, err + } + + if overrides.IsZero() { + return merged, nil + } + + for k, v := range overrides.Add { + merged.Add[k] = v + } + for k, v := range overrides.Set { + merged.Set[k] = v + } + + // Deduplicate removes. + removed := make(map[string]struct{}) + for _, k := range merged.Remove { + removed[k] = struct{}{} + } + for _, k := range overrides.Remove { + if _, ok := removed[k]; !ok { + merged.Remove = append(merged.Remove, k) + } + } + + return merged, nil +} diff --git a/agent/structs/config_entry_discoverychain_test.go b/agent/structs/config_entry_discoverychain_test.go index b1311991c..5cc43e468 100644 --- a/agent/structs/config_entry_discoverychain_test.go +++ b/agent/structs/config_entry_discoverychain_test.go @@ -1325,6 +1325,165 @@ func TestServiceSplitterConfigEntry(t *testing.T) { } } +func TestServiceSplitMergeParent(t *testing.T) { + + type testCase struct { + name string + split, parent, want *ServiceSplit + wantErr string + } + + run := func(t *testing.T, tc testCase) { + got, err := tc.split.MergeParent(tc.parent) + if tc.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, got) + } + } + + testCases := []testCase{ + { + name: "all header manip fields set", + split: &ServiceSplit{ + Weight: 50.0, + Service: "foo", + RequestHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{ + "child-only": "1", + "both-want-child": "2", + }, + Set: map[string]string{ + "child-only": "3", + "both-want-child": "4", + }, + Remove: []string{"child-only-req", "both-req"}, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{ + "child-only": "5", + "both-want-parent": "6", + }, + Set: map[string]string{ + "child-only": "7", + "both-want-parent": "8", + }, + Remove: []string{"child-only-resp", "both-resp"}, + }, + }, + parent: &ServiceSplit{ + Weight: 25.0, + Service: "bar", + RequestHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{ + "parent-only": "9", + "both-want-child": "10", + }, + Set: map[string]string{ + "parent-only": "11", + "both-want-child": "12", + }, + Remove: []string{"parent-only-req", "both-req"}, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{ + "parent-only": "13", + "both-want-parent": "14", + }, + Set: map[string]string{ + "parent-only": "15", + "both-want-parent": "16", + }, + Remove: []string{"parent-only-resp", "both-resp"}, + }, + }, + want: &ServiceSplit{ + Weight: 50.0, + Service: "foo", + RequestHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{ + "child-only": "1", + "both-want-child": "2", + "parent-only": "9", + }, + Set: map[string]string{ + "child-only": "3", + "both-want-child": "4", + "parent-only": "11", + }, + Remove: []string{"parent-only-req", "both-req", "child-only-req"}, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{ + "child-only": "5", + "parent-only": "13", + "both-want-parent": "14", + }, + Set: map[string]string{ + "child-only": "7", + "parent-only": "15", + "both-want-parent": "16", + }, + Remove: []string{"child-only-resp", "both-resp", "parent-only-resp"}, + }, + }, + }, + { + name: "no header manip", + split: &ServiceSplit{ + Weight: 50, + Service: "foo", + }, + parent: &ServiceSplit{ + Weight: 50, + Service: "bar", + }, + want: &ServiceSplit{ + Weight: 50, + Service: "foo", + }, + }, + { + name: "nil parent", + split: &ServiceSplit{ + Weight: 50, + Service: "foo", + }, + parent: nil, + want: &ServiceSplit{ + Weight: 50, + Service: "foo", + }, + }, + { + name: "nil child", + split: nil, + parent: &ServiceSplit{ + Weight: 50, + Service: "foo", + }, + want: &ServiceSplit{ + Weight: 50, + Service: "foo", + }, + }, + { + name: "both nil", + split: nil, + parent: nil, + want: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + run(t, tc) + }) + } +} + func TestServiceRouterConfigEntry(t *testing.T) { httpMatch := func(http *ServiceRouteHTTPMatch) *ServiceRouteMatch { diff --git a/agent/structs/config_entry_gateways.go b/agent/structs/config_entry_gateways.go index 551d2c2ab..4d6945aab 100644 --- a/agent/structs/config_entry_gateways.go +++ b/agent/structs/config_entry_gateways.go @@ -75,6 +75,10 @@ type IngressService struct { // using a "tcp" listener. Hosts []string + // Allow HTTP header manipulation to be configured. + RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` + ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` + Meta map[string]string `json:",omitempty"` EnterpriseMeta `hcl:",squash" mapstructure:",squash"` } @@ -164,10 +168,18 @@ func (e *IngressGatewayConfigEntry) Validate() error { } declaredHosts := make(map[string]bool) + serviceNames := make(map[ServiceID]struct{}) for i, s := range listener.Services { if err := validateInnerEnterpriseMeta(&s.EnterpriseMeta, &e.EnterpriseMeta); err != nil { return fmt.Errorf("Services[%d].%v", i, err) } + sn := NewServiceName(s.Name, &s.EnterpriseMeta) + if err := s.RequestHeaders.Validate(listener.Protocol); err != nil { + return fmt.Errorf("request headers %s (service %q on listener on port %d)", err, sn.String(), listener.Port) + } + if err := s.ResponseHeaders.Validate(listener.Protocol); err != nil { + return fmt.Errorf("response headers %s (service %q on listener on port %d)", err, sn.String(), listener.Port) + } if listener.Protocol == "tcp" { if s.Name == WildcardSpecifier { @@ -186,6 +198,11 @@ func (e *IngressGatewayConfigEntry) Validate() error { if s.NamespaceOrDefault() == WildcardSpecifier { return fmt.Errorf("Wildcard namespace is not supported for ingress services (listener on port %d)", listener.Port) } + sid := NewServiceID(s.Name, &s.EnterpriseMeta) + if _, ok := serviceNames[sid]; ok { + return fmt.Errorf("Service %s cannot be added multiple times (listener on port %d)", sid, listener.Port) + } + serviceNames[sid] = struct{}{} for _, h := range s.Hosts { if declaredHosts[h] { diff --git a/agent/structs/config_entry_gateways_test.go b/agent/structs/config_entry_gateways_test.go index d69e1387f..198bc4b0f 100644 --- a/agent/structs/config_entry_gateways_test.go +++ b/agent/structs/config_entry_gateways_test.go @@ -394,6 +394,115 @@ func TestIngressGatewayConfigEntry(t *testing.T) { }, validateErr: `Host '*' is not allowed when TLS is enabled, all hosts must be valid DNS records to add as a DNSSAN`, }, + "request header manip allowed for http(ish) protocol": { + entry: &IngressGatewayConfigEntry{ + Kind: "ingress-gateway", + Name: "ingress-web", + Listeners: []IngressListener{ + { + Port: 1111, + Protocol: "http", + Services: []IngressService{ + { + Name: "web", + RequestHeaders: &HTTPHeaderModifiers{ + Set: map[string]string{"x-foo": "bar"}, + }, + }, + }, + }, + { + Port: 2222, + Protocol: "http2", + Services: []IngressService{ + { + Name: "web2", + ResponseHeaders: &HTTPHeaderModifiers{ + Set: map[string]string{"x-foo": "bar"}, + }, + }, + }, + }, + { + Port: 3333, + Protocol: "grpc", + Services: []IngressService{ + { + Name: "api", + ResponseHeaders: &HTTPHeaderModifiers{ + Remove: []string{"x-grpc-internal"}, + }, + }, + }, + }, + }, + }, + }, + "request header manip not allowed for non-http protocol": { + entry: &IngressGatewayConfigEntry{ + Kind: "ingress-gateway", + Name: "ingress-web", + Listeners: []IngressListener{ + { + Port: 1111, + Protocol: "tcp", + Services: []IngressService{ + { + Name: "db", + RequestHeaders: &HTTPHeaderModifiers{ + Set: map[string]string{"x-foo": "bar"}, + }, + }, + }, + }, + }, + }, + validateErr: "request headers only valid for http", + }, + "response header manip not allowed for non-http protocol": { + entry: &IngressGatewayConfigEntry{ + Kind: "ingress-gateway", + Name: "ingress-web", + Listeners: []IngressListener{ + { + Port: 1111, + Protocol: "tcp", + Services: []IngressService{ + { + Name: "db", + ResponseHeaders: &HTTPHeaderModifiers{ + Remove: []string{"x-foo"}, + }, + }, + }, + }, + }, + }, + validateErr: "response headers only valid for http", + }, + "duplicate services not allowed": { + entry: &IngressGatewayConfigEntry{ + Kind: "ingress-gateway", + Name: "ingress-web", + Listeners: []IngressListener{ + { + Port: 1111, + Protocol: "http", + Services: []IngressService{ + { + Name: "web", + }, + { + Name: "web", + }, + }, + }, + }, + }, + // Match only the last part of the exected error because the service name + // differs between Ent and OSS default/default/web vs web + validateErr: "cannot be added multiple times (listener on port 1111)", + }, } testConfigEntryNormalizeAndValidate(t, cases) diff --git a/agent/structs/config_entry_test.go b/agent/structs/config_entry_test.go index 354c24e5f..4f0871854 100644 --- a/agent/structs/config_entry_test.go +++ b/agent/structs/config_entry_test.go @@ -463,6 +463,24 @@ func TestDecodeConfigEntry(t *testing.T) { num_retries = 12345 retry_on_connect_failure = true retry_on_status_codes = [401, 209] + request_headers { + add { + x-foo = "bar" + } + set { + bar = "baz" + } + remove = ["qux"] + } + response_headers { + add { + x-foo = "bar" + } + set { + bar = "baz" + } + remove = ["qux"] + } } }, { @@ -546,6 +564,24 @@ func TestDecodeConfigEntry(t *testing.T) { NumRetries = 12345 RetryOnConnectFailure = true RetryOnStatusCodes = [401, 209] + RequestHeaders { + Add { + x-foo = "bar" + } + Set { + bar = "baz" + } + Remove = ["qux"] + } + ResponseHeaders { + Add { + x-foo = "bar" + } + Set { + bar = "baz" + } + Remove = ["qux"] + } } }, { @@ -629,6 +665,16 @@ func TestDecodeConfigEntry(t *testing.T) { NumRetries: 12345, RetryOnConnectFailure: true, RetryOnStatusCodes: []uint32{401, 209}, + RequestHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{"x-foo": "bar"}, + Set: map[string]string{"bar": "baz"}, + Remove: []string{"qux"}, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{"x-foo": "bar"}, + Set: map[string]string{"bar": "baz"}, + Remove: []string{"qux"}, + }, }, }, { @@ -674,13 +720,31 @@ func TestDecodeConfigEntry(t *testing.T) { } splits = [ { - weight = 99.1 - service_subset = "v1" + weight = 99.1 + service_subset = "v1" + request_headers { + add { + foo = "bar" + } + set { + bar = "baz" + } + remove = ["qux"] + } + response_headers { + add { + foo = "bar" + } + set { + bar = "baz" + } + remove = ["qux"] + } }, { - weight = 0.9 - service = "other" - namespace = "alt" + weight = 0.9 + service = "other" + namespace = "alt" }, ] `, @@ -693,13 +757,31 @@ func TestDecodeConfigEntry(t *testing.T) { } Splits = [ { - Weight = 99.1 - ServiceSubset = "v1" + Weight = 99.1 + ServiceSubset = "v1" + RequestHeaders { + Add { + foo = "bar" + } + Set { + bar = "baz" + } + Remove = ["qux"] + } + ResponseHeaders { + Add { + foo = "bar" + } + Set { + bar = "baz" + } + Remove = ["qux"] + } }, { - Weight = 0.9 - Service = "other" - Namespace = "alt" + Weight = 0.9 + Service = "other" + Namespace = "alt" }, ] `, @@ -714,6 +796,16 @@ func TestDecodeConfigEntry(t *testing.T) { { Weight: 99.1, ServiceSubset: "v1", + RequestHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{"foo": "bar"}, + Set: map[string]string{"bar": "baz"}, + Remove: []string{"qux"}, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{"foo": "bar"}, + Set: map[string]string{"bar": "baz"}, + Remove: []string{"qux"}, + }, }, { Weight: 0.9, @@ -1037,6 +1129,24 @@ func TestDecodeConfigEntry(t *testing.T) { }, { name = "db" + request_headers { + add { + foo = "bar" + } + set { + bar = "baz" + } + remove = ["qux"] + } + response_headers { + add { + foo = "bar" + } + set { + bar = "baz" + } + remove = ["qux"] + } } ] }, @@ -1081,6 +1191,24 @@ func TestDecodeConfigEntry(t *testing.T) { }, { Name = "db" + RequestHeaders { + Add { + foo = "bar" + } + Set { + bar = "baz" + } + Remove = ["qux"] + } + ResponseHeaders { + Add { + foo = "bar" + } + Set { + bar = "baz" + } + Remove = ["qux"] + } } ] }, @@ -1125,6 +1253,16 @@ func TestDecodeConfigEntry(t *testing.T) { }, { Name: "db", + RequestHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{"foo": "bar"}, + Set: map[string]string{"bar": "baz"}, + Remove: []string{"qux"}, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Add: map[string]string{"foo": "bar"}, + Set: map[string]string{"bar": "baz"}, + Remove: []string{"qux"}, + }, }, }, }, diff --git a/agent/structs/connect_proxy_config.go b/agent/structs/connect_proxy_config.go index 984adfb28..5cf553e67 100644 --- a/agent/structs/connect_proxy_config.go +++ b/agent/structs/connect_proxy_config.go @@ -375,9 +375,11 @@ type Upstream struct { // MeshGateway is the configuration for mesh gateway usage of this upstream MeshGateway MeshGatewayConfig `json:",omitempty" alias:"mesh_gateway"` - // IngressHosts are a list of hosts that should route to this upstream from - // an ingress gateway. This cannot and should not be set by a user, it is - // used internally to store the association of hosts to an upstream service. + // IngressHosts are a list of hosts that should route to this upstream from an + // ingress gateway. This cannot and should not be set by a user, it is used + // internally to store the association of hosts to an upstream service. + // TODO(banks): we shouldn't need this any more now we pass through full + // listener config in the ingress snapshot. IngressHosts []string `json:"-" bexpr:"-"` // CentrallyConfigured indicates whether the upstream was defined in a proxy diff --git a/agent/structs/discovery_chain.go b/agent/structs/discovery_chain.go index 8ff02b197..86c24515d 100644 --- a/agent/structs/discovery_chain.go +++ b/agent/structs/discovery_chain.go @@ -192,6 +192,13 @@ type DiscoveryRoute struct { // compiled form of ServiceSplit type DiscoverySplit struct { + Definition *ServiceSplit `json:",omitempty"` + // Weight is not necessarily a duplicate of Definition.Weight since when + // multiple splits are compiled down to a single set of splits the effective + // weight of a split leg might not be the same as in the original definition. + // Proxies should use this compiled weight. The Definition is provided above + // for any other significant configuration that the proxy might need to apply + // to that leg of the split. Weight float32 `json:",omitempty"` NextNode string `json:",omitempty"` } diff --git a/agent/xds/routes.go b/agent/xds/routes.go index 37124ca4e..1a5d6403b 100644 --- a/agent/xds/routes.go +++ b/agent/xds/routes.go @@ -7,6 +7,7 @@ import ( "strings" "time" + envoy_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_route_v3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" envoy_matcher_v3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" @@ -29,7 +30,11 @@ func (s *ResourceGenerator) routesFromSnapshot(cfgSnap *proxycfg.ConfigSnapshot) case structs.ServiceKindConnectProxy: return s.routesForConnectProxy(cfgSnap.ConnectProxy.DiscoveryChain) case structs.ServiceKindIngressGateway: - return s.routesForIngressGateway(cfgSnap.IngressGateway.Upstreams, cfgSnap.IngressGateway.DiscoveryChain) + return s.routesForIngressGateway( + cfgSnap.IngressGateway.Listeners, + cfgSnap.IngressGateway.Upstreams, + cfgSnap.IngressGateway.DiscoveryChain, + ) case structs.ServiceKindTerminatingGateway: return s.routesFromSnapshotTerminatingGateway(cfgSnap) case structs.ServiceKindMeshGateway: @@ -160,6 +165,7 @@ func makeNamedDefaultRouteWithLB(clusterName string, lb *structs.LoadBalancer, a // routesForIngressGateway returns the xDS API representation of the // "routes" in the snapshot. func (s *ResourceGenerator) routesForIngressGateway( + listeners map[proxycfg.IngressListenerKey]structs.IngressListener, upstreams map[proxycfg.IngressListenerKey]structs.Upstreams, chains map[string]*structs.CompiledDiscoveryChain, ) ([]proto.Message, error) { @@ -190,6 +196,42 @@ func (s *ResourceGenerator) routesForIngressGateway( if err != nil { return nil, err } + + // See if we need to configure any special settings on this route config + if lCfg, ok := listeners[listenerKey]; ok { + if is := findIngressServiceMatchingUpstream(lCfg, u); is != nil { + // Set up any header manipulation we need + if is.RequestHeaders != nil { + virtualHost.RequestHeadersToAdd = append( + virtualHost.RequestHeadersToAdd, + makeHeadersValueOptions(is.RequestHeaders.Add, true)..., + ) + virtualHost.RequestHeadersToAdd = append( + virtualHost.RequestHeadersToAdd, + makeHeadersValueOptions(is.RequestHeaders.Set, false)..., + ) + virtualHost.RequestHeadersToRemove = append( + virtualHost.RequestHeadersToRemove, + is.RequestHeaders.Remove..., + ) + } + if is.ResponseHeaders != nil { + virtualHost.ResponseHeadersToAdd = append( + virtualHost.ResponseHeadersToAdd, + makeHeadersValueOptions(is.ResponseHeaders.Add, true)..., + ) + virtualHost.ResponseHeadersToAdd = append( + virtualHost.ResponseHeadersToAdd, + makeHeadersValueOptions(is.ResponseHeaders.Set, false)..., + ) + virtualHost.ResponseHeadersToRemove = append( + virtualHost.ResponseHeadersToRemove, + is.ResponseHeaders.Remove..., + ) + } + } + } + upstreamRoute.VirtualHosts = append(upstreamRoute.VirtualHosts, virtualHost) } @@ -199,6 +241,36 @@ func (s *ResourceGenerator) routesForIngressGateway( return result, nil } +func makeHeadersValueOptions(vals map[string]string, add bool) []*envoy_core_v3.HeaderValueOption { + opts := make([]*envoy_core_v3.HeaderValueOption, 0, len(vals)) + for k, v := range vals { + o := &envoy_core_v3.HeaderValueOption{ + Header: &envoy_core_v3.HeaderValue{ + Key: k, + Value: v, + }, + Append: makeBoolValue(add), + } + opts = append(opts, o) + } + return opts +} + +func findIngressServiceMatchingUpstream(l structs.IngressListener, u structs.Upstream) *structs.IngressService { + // Hunt through for the matching service. We validate now that there is + // only one IngressService for each unique name although originally that + // wasn't checked as it didn't matter. Assume there is only one now + // though! + wantSID := u.DestinationID() + for _, s := range l.Services { + sid := structs.NewServiceID(s.Name, &s.EnterpriseMeta) + if wantSID.Matches(sid) { + return &s + } + } + return nil +} + func generateUpstreamIngressDomains(listenerKey proxycfg.IngressListenerKey, u structs.Upstream) []string { var domains []string domainsSet := make(map[string]bool) @@ -283,24 +355,23 @@ func makeUpstreamRouteForDiscoveryChain( return nil, err } - if err := injectLBToRouteAction(lb, routeAction.Route); err != nil { - return nil, fmt.Errorf("failed to apply load balancer configuration to route action: %v", err) - } - case structs.DiscoveryGraphNodeTypeResolver: routeAction = makeRouteActionForChainCluster(nextNode.Resolver.Target, chain) - if err := injectLBToRouteAction(lb, routeAction.Route); err != nil { - return nil, fmt.Errorf("failed to apply load balancer configuration to route action: %v", err) - } - default: return nil, fmt.Errorf("unexpected graph node after route %q", nextNode.Type) } + if err := injectLBToRouteAction(lb, routeAction.Route); err != nil { + return nil, fmt.Errorf("failed to apply load balancer configuration to route action: %v", err) + } + // TODO(rb): Better help handle the envoy case where you need (prefix=/foo/,rewrite=/) and (exact=/foo,rewrite=/) to do a full rewrite destination := discoveryRoute.Definition.Destination + + route := &envoy_route_v3.Route{} + if destination != nil { if destination.PrefixRewrite != "" { routeAction.Route.PrefixRewrite = destination.PrefixRewrite @@ -331,12 +402,16 @@ func makeUpstreamRouteForDiscoveryChain( routeAction.Route.RetryPolicy = retryPolicy } + + if err := injectHeaderManipToRoute(destination, route); err != nil { + return nil, fmt.Errorf("failed to apply header manipulation configuration to route: %v", err) + } } - routes = append(routes, &envoy_route_v3.Route{ - Match: routeMatch, - Action: routeAction, - }) + route.Match = routeMatch + route.Action = routeAction + + routes = append(routes, route) } case structs.DiscoveryGraphNodeTypeSplitter: @@ -558,6 +633,9 @@ func makeRouteActionForSplitter(splits []*structs.DiscoverySplit, chain *structs Weight: makeUint32Value(int(split.Weight * 100)), Name: clusterName, } + if err := injectHeaderManipToWeightedCluster(split.Definition, cw); err != nil { + return nil, err + } clusters = append(clusters, cw) } @@ -642,3 +720,67 @@ func injectLBToRouteAction(lb *structs.LoadBalancer, action *envoy_route_v3.Rout action.HashPolicy = result return nil } + +func injectHeaderManipToRoute(dest *structs.ServiceRouteDestination, r *envoy_route_v3.Route) error { + if !dest.RequestHeaders.IsZero() { + r.RequestHeadersToAdd = append( + r.RequestHeadersToAdd, + makeHeadersValueOptions(dest.RequestHeaders.Add, true)..., + ) + r.RequestHeadersToAdd = append( + r.RequestHeadersToAdd, + makeHeadersValueOptions(dest.RequestHeaders.Set, false)..., + ) + r.RequestHeadersToRemove = append( + r.RequestHeadersToRemove, + dest.RequestHeaders.Remove..., + ) + } + if !dest.ResponseHeaders.IsZero() { + r.ResponseHeadersToAdd = append( + r.ResponseHeadersToAdd, + makeHeadersValueOptions(dest.ResponseHeaders.Add, true)..., + ) + r.ResponseHeadersToAdd = append( + r.ResponseHeadersToAdd, + makeHeadersValueOptions(dest.ResponseHeaders.Set, false)..., + ) + r.ResponseHeadersToRemove = append( + r.ResponseHeadersToRemove, + dest.ResponseHeaders.Remove..., + ) + } + return nil +} + +func injectHeaderManipToWeightedCluster(split *structs.ServiceSplit, c *envoy_route_v3.WeightedCluster_ClusterWeight) error { + if !split.RequestHeaders.IsZero() { + c.RequestHeadersToAdd = append( + c.RequestHeadersToAdd, + makeHeadersValueOptions(split.RequestHeaders.Add, true)..., + ) + c.RequestHeadersToAdd = append( + c.RequestHeadersToAdd, + makeHeadersValueOptions(split.RequestHeaders.Set, false)..., + ) + c.RequestHeadersToRemove = append( + c.RequestHeadersToRemove, + split.RequestHeaders.Remove..., + ) + } + if !split.ResponseHeaders.IsZero() { + c.ResponseHeadersToAdd = append( + c.ResponseHeadersToAdd, + makeHeadersValueOptions(split.ResponseHeaders.Add, true)..., + ) + c.ResponseHeadersToAdd = append( + c.ResponseHeadersToAdd, + makeHeadersValueOptions(split.ResponseHeaders.Set, false)..., + ) + c.ResponseHeadersToRemove = append( + c.ResponseHeadersToRemove, + split.ResponseHeaders.Remove..., + ) + } + return nil +} diff --git a/agent/xds/routes_test.go b/agent/xds/routes_test.go index cfb838e86..63f78b7c6 100644 --- a/agent/xds/routes_test.go +++ b/agent/xds/routes_test.go @@ -189,6 +189,33 @@ func TestRoutesFromSnapshot(t *testing.T) { } }, }, + { + name: "ingress-with-chain-and-router-header-manip", + create: proxycfg.TestConfigSnapshotIngressWithRouter, + setup: func(snap *proxycfg.ConfigSnapshot) { + k := proxycfg.IngressListenerKey{Port: 9191, Protocol: "http"} + l := snap.IngressGateway.Listeners[k] + l.Services[0].RequestHeaders = &structs.HTTPHeaderModifiers{ + Add: map[string]string{ + "foo": "bar", + }, + Set: map[string]string{ + "bar": "baz", + }, + Remove: []string{"qux"}, + } + l.Services[0].ResponseHeaders = &structs.HTTPHeaderModifiers{ + Add: map[string]string{ + "foo": "bar", + }, + Set: map[string]string{ + "bar": "baz", + }, + Remove: []string{"qux"}, + } + snap.IngressGateway.Listeners[k] = l + }, + }, { name: "terminating-gateway-lb-config", create: proxycfg.TestConfigSnapshotTerminatingGateway, diff --git a/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.envoy-1-18-x.golden b/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.envoy-1-18-x.golden index 7e2f58b0d..5f48cd972 100644 --- a/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.envoy-1-18-x.golden +++ b/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.envoy-1-18-x.golden @@ -343,6 +343,52 @@ } } }, + { + "match": { + "path": "/header-manip" + }, + "route": { + "cluster": "header-manip.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + }, + "requestHeadersToAdd": [ + { + "header": { + "key": "request", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "response", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + }, { "match": { "prefix": "/" diff --git a/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.v2compat.envoy-1-16-x.golden b/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.v2compat.envoy-1-16-x.golden index 85d873ab0..e06e74a39 100644 --- a/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.v2compat.envoy-1-16-x.golden +++ b/agent/xds/testdata/routes/connect-proxy-with-chain-and-router.v2compat.envoy-1-16-x.golden @@ -343,6 +343,52 @@ } } }, + { + "match": { + "path": "/header-manip" + }, + "route": { + "cluster": "header-manip.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + }, + "requestHeadersToAdd": [ + { + "header": { + "key": "request", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "response", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + }, { "match": { "prefix": "/" diff --git a/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.envoy-1-18-x.golden b/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.envoy-1-18-x.golden index 5da88c61d..9dde10444 100644 --- a/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.envoy-1-18-x.golden +++ b/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.envoy-1-18-x.golden @@ -20,15 +20,69 @@ "clusters": [ { "name": "big-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 9550 + "weight": 9550, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ] }, { "name": "goldilocks-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 400 + "weight": 400, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ] }, { "name": "lil-bit-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 50 + "weight": 50, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ] } ], "totalWeight": 10000 diff --git a/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.v2compat.envoy-1-16-x.golden b/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.v2compat.envoy-1-16-x.golden index efe364bad..e3674f539 100644 --- a/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.v2compat.envoy-1-16-x.golden +++ b/agent/xds/testdata/routes/connect-proxy-with-chain-and-splitter.v2compat.envoy-1-16-x.golden @@ -20,15 +20,69 @@ "clusters": [ { "name": "big-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 9550 + "weight": 9550, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ] }, { "name": "goldilocks-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 400 + "weight": 400, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ] }, { "name": "lil-bit-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 50 + "weight": 50, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ] } ], "totalWeight": 10000 diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.envoy-1-18-x.golden b/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.envoy-1-18-x.golden new file mode 100644 index 000000000..649b37240 --- /dev/null +++ b/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.envoy-1-18-x.golden @@ -0,0 +1,447 @@ +{ + "versionInfo": "00000001", + "resources": [ + { + "@type": "type.googleapis.com/envoy.config.route.v3.RouteConfiguration", + "name": "9191", + "virtualHosts": [ + { + "name": "db", + "domains": [ + "db.ingress.*", + "db.ingress.*:9191" + ], + "routes": [ + { + "match": { + "prefix": "/prefix" + }, + "route": { + "cluster": "prefix.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "path": "/exact" + }, + "route": { + "cluster": "exact.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "safeRegex": { + "googleRe2": { + + }, + "regex": "/regex" + } + }, + "route": { + "cluster": "regex.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "presentMatch": true + } + ] + }, + "route": { + "cluster": "hdr-present.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "presentMatch": true, + "invertMatch": true + } + ] + }, + "route": { + "cluster": "hdr-not-present.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "exactMatch": "exact" + } + ] + }, + "route": { + "cluster": "hdr-exact.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "prefixMatch": "prefix" + } + ] + }, + "route": { + "cluster": "hdr-prefix.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "suffixMatch": "suffix" + } + ] + }, + "route": { + "cluster": "hdr-suffix.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "safeRegexMatch": { + "googleRe2": { + + }, + "regex": "regex" + } + } + ] + }, + "route": { + "cluster": "hdr-regex.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": ":method", + "safeRegexMatch": { + "googleRe2": { + + }, + "regex": "GET|PUT" + } + } + ] + }, + "route": { + "cluster": "just-methods.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "exactMatch": "exact" + }, + { + "name": ":method", + "safeRegexMatch": { + "googleRe2": { + + }, + "regex": "GET|PUT" + } + } + ] + }, + "route": { + "cluster": "hdr-exact-with-method.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "queryParameters": [ + { + "name": "secretparam1", + "stringMatch": { + "exact": "exact" + } + } + ] + }, + "route": { + "cluster": "prm-exact.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "queryParameters": [ + { + "name": "secretparam2", + "stringMatch": { + "safeRegex": { + "googleRe2": { + + }, + "regex": "regex" + } + } + } + ] + }, + "route": { + "cluster": "prm-regex.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "queryParameters": [ + { + "name": "secretparam3", + "presentMatch": true + } + ] + }, + "route": { + "cluster": "prm-present.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "nil-match.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "empty-match-1.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "empty-match-2.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/prefix" + }, + "route": { + "cluster": "prefix-rewrite-1.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "prefixRewrite": "/" + } + }, + { + "match": { + "prefix": "/prefix" + }, + "route": { + "cluster": "prefix-rewrite-2.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "prefixRewrite": "/nested/newlocation" + } + }, + { + "match": { + "prefix": "/timeout" + }, + "route": { + "cluster": "req-timeout.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "timeout": "33s" + } + }, + { + "match": { + "prefix": "/retry-connect" + }, + "route": { + "cluster": "retry-connect.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "retryPolicy": { + "retryOn": "connect-failure", + "numRetries": 15 + } + } + }, + { + "match": { + "prefix": "/retry-codes" + }, + "route": { + "cluster": "retry-codes.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "retryPolicy": { + "retryOn": "retriable-status-codes", + "numRetries": 15, + "retriableStatusCodes": [ + 401, + 409, + 451 + ] + } + } + }, + { + "match": { + "prefix": "/retry-both" + }, + "route": { + "cluster": "retry-both.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "retryPolicy": { + "retryOn": "connect-failure,retriable-status-codes", + "retriableStatusCodes": [ + 401, + 409, + 451 + ] + } + } + }, + { + "match": { + "prefix": "/split-3-ways" + }, + "route": { + "weightedClusters": { + "clusters": [ + { + "name": "big-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "weight": 9550 + }, + { + "name": "goldilocks-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "weight": 400 + }, + { + "name": "lil-bit-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "weight": 50 + } + ], + "totalWeight": 10000 + } + } + }, + { + "match": { + "path": "/header-manip" + }, + "route": { + "cluster": "header-manip.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + }, + "requestHeadersToAdd": [ + { + "header": { + "key": "request", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "response", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "db.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + } + ], + "requestHeadersToAdd": [ + { + "header": { + "key": "foo", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "foo", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + } + ], + "validateClusters": true + } + ], + "typeUrl": "type.googleapis.com/envoy.config.route.v3.RouteConfiguration", + "nonce": "00000001" +} \ No newline at end of file diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.v2compat.envoy-1-16-x.golden b/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.v2compat.envoy-1-16-x.golden new file mode 100644 index 000000000..9d70b0681 --- /dev/null +++ b/agent/xds/testdata/routes/ingress-with-chain-and-router-header-manip.v2compat.envoy-1-16-x.golden @@ -0,0 +1,447 @@ +{ + "versionInfo": "00000001", + "resources": [ + { + "@type": "type.googleapis.com/envoy.api.v2.RouteConfiguration", + "name": "9191", + "virtualHosts": [ + { + "name": "db", + "domains": [ + "db.ingress.*", + "db.ingress.*:9191" + ], + "routes": [ + { + "match": { + "prefix": "/prefix" + }, + "route": { + "cluster": "prefix.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "path": "/exact" + }, + "route": { + "cluster": "exact.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "safeRegex": { + "googleRe2": { + + }, + "regex": "/regex" + } + }, + "route": { + "cluster": "regex.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "presentMatch": true + } + ] + }, + "route": { + "cluster": "hdr-present.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "presentMatch": true, + "invertMatch": true + } + ] + }, + "route": { + "cluster": "hdr-not-present.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "exactMatch": "exact" + } + ] + }, + "route": { + "cluster": "hdr-exact.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "prefixMatch": "prefix" + } + ] + }, + "route": { + "cluster": "hdr-prefix.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "suffixMatch": "suffix" + } + ] + }, + "route": { + "cluster": "hdr-suffix.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "safeRegexMatch": { + "googleRe2": { + + }, + "regex": "regex" + } + } + ] + }, + "route": { + "cluster": "hdr-regex.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": ":method", + "safeRegexMatch": { + "googleRe2": { + + }, + "regex": "GET|PUT" + } + } + ] + }, + "route": { + "cluster": "just-methods.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "headers": [ + { + "name": "x-debug", + "exactMatch": "exact" + }, + { + "name": ":method", + "safeRegexMatch": { + "googleRe2": { + + }, + "regex": "GET|PUT" + } + } + ] + }, + "route": { + "cluster": "hdr-exact-with-method.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "queryParameters": [ + { + "name": "secretparam1", + "stringMatch": { + "exact": "exact" + } + } + ] + }, + "route": { + "cluster": "prm-exact.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "queryParameters": [ + { + "name": "secretparam2", + "stringMatch": { + "safeRegex": { + "googleRe2": { + + }, + "regex": "regex" + } + } + } + ] + }, + "route": { + "cluster": "prm-regex.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/", + "queryParameters": [ + { + "name": "secretparam3", + "presentMatch": true + } + ] + }, + "route": { + "cluster": "prm-present.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "nil-match.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "empty-match-1.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "empty-match-2.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + }, + { + "match": { + "prefix": "/prefix" + }, + "route": { + "cluster": "prefix-rewrite-1.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "prefixRewrite": "/" + } + }, + { + "match": { + "prefix": "/prefix" + }, + "route": { + "cluster": "prefix-rewrite-2.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "prefixRewrite": "/nested/newlocation" + } + }, + { + "match": { + "prefix": "/timeout" + }, + "route": { + "cluster": "req-timeout.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "timeout": "33s" + } + }, + { + "match": { + "prefix": "/retry-connect" + }, + "route": { + "cluster": "retry-connect.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "retryPolicy": { + "retryOn": "connect-failure", + "numRetries": 15 + } + } + }, + { + "match": { + "prefix": "/retry-codes" + }, + "route": { + "cluster": "retry-codes.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "retryPolicy": { + "retryOn": "retriable-status-codes", + "numRetries": 15, + "retriableStatusCodes": [ + 401, + 409, + 451 + ] + } + } + }, + { + "match": { + "prefix": "/retry-both" + }, + "route": { + "cluster": "retry-both.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "retryPolicy": { + "retryOn": "connect-failure,retriable-status-codes", + "retriableStatusCodes": [ + 401, + 409, + 451 + ] + } + } + }, + { + "match": { + "prefix": "/split-3-ways" + }, + "route": { + "weightedClusters": { + "clusters": [ + { + "name": "big-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "weight": 9550 + }, + { + "name": "goldilocks-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "weight": 400 + }, + { + "name": "lil-bit-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", + "weight": 50 + } + ], + "totalWeight": 10000 + } + } + }, + { + "match": { + "path": "/header-manip" + }, + "route": { + "cluster": "header-manip.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + }, + "requestHeadersToAdd": [ + { + "header": { + "key": "request", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "response", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + }, + { + "match": { + "prefix": "/" + }, + "route": { + "cluster": "db.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + } + } + ], + "requestHeadersToAdd": [ + { + "header": { + "key": "foo", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "foo", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + } + ], + "validateClusters": true + } + ], + "typeUrl": "type.googleapis.com/envoy.api.v2.RouteConfiguration", + "nonce": "00000001" +} \ No newline at end of file diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-router.envoy-1-18-x.golden b/agent/xds/testdata/routes/ingress-with-chain-and-router.envoy-1-18-x.golden index 26da96051..ddd97143d 100644 --- a/agent/xds/testdata/routes/ingress-with-chain-and-router.envoy-1-18-x.golden +++ b/agent/xds/testdata/routes/ingress-with-chain-and-router.envoy-1-18-x.golden @@ -344,6 +344,52 @@ } } }, + { + "match": { + "path": "/header-manip" + }, + "route": { + "cluster": "header-manip.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + }, + "requestHeadersToAdd": [ + { + "header": { + "key": "request", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "response", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + }, { "match": { "prefix": "/" diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-router.v2compat.envoy-1-16-x.golden b/agent/xds/testdata/routes/ingress-with-chain-and-router.v2compat.envoy-1-16-x.golden index 70048c7b1..787beacb4 100644 --- a/agent/xds/testdata/routes/ingress-with-chain-and-router.v2compat.envoy-1-16-x.golden +++ b/agent/xds/testdata/routes/ingress-with-chain-and-router.v2compat.envoy-1-16-x.golden @@ -344,6 +344,52 @@ } } }, + { + "match": { + "path": "/header-manip" + }, + "route": { + "cluster": "header-manip.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul" + }, + "requestHeadersToAdd": [ + { + "header": { + "key": "request", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "requestHeadersToRemove": [ + "qux" + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "response", + "value": "bar" + }, + "append": true + }, + { + "header": { + "key": "bar", + "value": "baz" + }, + "append": false + } + ], + "responseHeadersToRemove": [ + "qux" + ] + }, { "match": { "prefix": "/" diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-splitter.envoy-1-18-x.golden b/agent/xds/testdata/routes/ingress-with-chain-and-splitter.envoy-1-18-x.golden index 87b3422e4..225d4ab91 100644 --- a/agent/xds/testdata/routes/ingress-with-chain-and-splitter.envoy-1-18-x.golden +++ b/agent/xds/testdata/routes/ingress-with-chain-and-splitter.envoy-1-18-x.golden @@ -21,15 +21,69 @@ "clusters": [ { "name": "big-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 9550 + "weight": 9550, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ] }, { "name": "goldilocks-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 400 + "weight": 400, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ] }, { "name": "lil-bit-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 50 + "weight": 50, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ] } ], "totalWeight": 10000 diff --git a/agent/xds/testdata/routes/ingress-with-chain-and-splitter.v2compat.envoy-1-16-x.golden b/agent/xds/testdata/routes/ingress-with-chain-and-splitter.v2compat.envoy-1-16-x.golden index 4601126e1..ab3e6ac47 100644 --- a/agent/xds/testdata/routes/ingress-with-chain-and-splitter.v2compat.envoy-1-16-x.golden +++ b/agent/xds/testdata/routes/ingress-with-chain-and-splitter.v2compat.envoy-1-16-x.golden @@ -21,15 +21,69 @@ "clusters": [ { "name": "big-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 9550 + "weight": 9550, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "big" + }, + "append": false + } + ] }, { "name": "goldilocks-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 400 + "weight": 400, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "goldilocks" + }, + "append": false + } + ] }, { "name": "lil-bit-side.default.dc1.internal.11111111-2222-3333-4444-555555555555.consul", - "weight": 50 + "weight": 50, + "requestHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ], + "responseHeadersToAdd": [ + { + "header": { + "key": "x-split-leg", + "value": "small" + }, + "append": false + } + ] } ], "totalWeight": 10000 diff --git a/api/config_entry_discoverychain.go b/api/config_entry_discoverychain.go index 5419292fe..384e2ce47 100644 --- a/api/config_entry_discoverychain.go +++ b/api/config_entry_discoverychain.go @@ -61,14 +61,16 @@ type ServiceRouteHTTPMatchQueryParam struct { } type ServiceRouteDestination struct { - Service string `json:",omitempty"` - ServiceSubset string `json:",omitempty" alias:"service_subset"` - Namespace string `json:",omitempty"` - PrefixRewrite string `json:",omitempty" alias:"prefix_rewrite"` - RequestTimeout time.Duration `json:",omitempty" alias:"request_timeout"` - NumRetries uint32 `json:",omitempty" alias:"num_retries"` - RetryOnConnectFailure bool `json:",omitempty" alias:"retry_on_connect_failure"` - RetryOnStatusCodes []uint32 `json:",omitempty" alias:"retry_on_status_codes"` + Service string `json:",omitempty"` + ServiceSubset string `json:",omitempty" alias:"service_subset"` + Namespace string `json:",omitempty"` + PrefixRewrite string `json:",omitempty" alias:"prefix_rewrite"` + RequestTimeout time.Duration `json:",omitempty" alias:"request_timeout"` + NumRetries uint32 `json:",omitempty" alias:"num_retries"` + RetryOnConnectFailure bool `json:",omitempty" alias:"retry_on_connect_failure"` + RetryOnStatusCodes []uint32 `json:",omitempty" alias:"retry_on_status_codes"` + RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` + ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` } func (e *ServiceRouteDestination) MarshalJSON() ([]byte, error) { @@ -127,10 +129,12 @@ func (e *ServiceSplitterConfigEntry) GetCreateIndex() uint64 { return e.Crea func (e *ServiceSplitterConfigEntry) GetModifyIndex() uint64 { return e.ModifyIndex } type ServiceSplit struct { - Weight float32 - Service string `json:",omitempty"` - ServiceSubset string `json:",omitempty" alias:"service_subset"` - Namespace string `json:",omitempty"` + Weight float32 + Service string `json:",omitempty"` + ServiceSubset string `json:",omitempty" alias:"service_subset"` + Namespace string `json:",omitempty"` + RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` + ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` } type ServiceResolverConfigEntry struct { @@ -287,3 +291,21 @@ type CookieConfig struct { // The path to set for the cookie Path string `json:",omitempty"` } + +// HTTPHeaderModifiers is a set of rules for HTTP header modification that +// should be performed by proxies as the request passes through them. It can +// operate on either request or response headers depending on the context in +// which it is used. +type HTTPHeaderModifiers struct { + // Add is a set of name -> value pairs that should be appended to the request + // or response (i.e. allowing duplicates if the same header already exists). + Add map[string]string `json:",omitempty"` + + // Set is a set of name -> value pairs that should be added to the request or + // response, overwriting any existing header values of the same name. + Set map[string]string `json:",omitempty"` + + // Remove is the set of header names that should be stripped from the request + // or response. + Remove []string `json:",omitempty"` +} diff --git a/api/config_entry_discoverychain_test.go b/api/config_entry_discoverychain_test.go index 5f8b81e07..aae84eb36 100644 --- a/api/config_entry_discoverychain_test.go +++ b/api/config_entry_discoverychain_test.go @@ -193,6 +193,14 @@ func TestAPI_ConfigEntry_DiscoveryChain(t *testing.T) { Service: "test-failover", ServiceSubset: "v1", Namespace: defaultNamespace, + RequestHeaders: &HTTPHeaderModifiers{ + Set: map[string]string{ + "x-foo": "bar", + }, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Remove: []string{"x-foo"}, + }, }, { Weight: 10, @@ -235,6 +243,14 @@ func TestAPI_ConfigEntry_DiscoveryChain(t *testing.T) { NumRetries: 5, RetryOnConnectFailure: true, RetryOnStatusCodes: []uint32{500, 503, 401}, + RequestHeaders: &HTTPHeaderModifiers{ + Set: map[string]string{ + "x-foo": "bar", + }, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Remove: []string{"x-foo"}, + }, }, }, }, diff --git a/api/config_entry_gateways.go b/api/config_entry_gateways.go index 822c093f2..47369d922 100644 --- a/api/config_entry_gateways.go +++ b/api/config_entry_gateways.go @@ -83,6 +83,10 @@ type IngressService struct { // using a "tcp" listener. Hosts []string + // Allow HTTP header manipulation to be configured. + RequestHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"request_headers"` + ResponseHeaders *HTTPHeaderModifiers `json:",omitempty" alias:"response_headers"` + // 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 c98ab321c..22b15259b 100644 --- a/api/config_entry_gateways_test.go +++ b/api/config_entry_gateways_test.go @@ -78,6 +78,14 @@ func TestAPI_ConfigEntries_IngressGateway(t *testing.T) { { Name: "asdf", Hosts: []string{"test.example.com"}, + RequestHeaders: &HTTPHeaderModifiers{ + Set: map[string]string{ + "x-foo": "bar", + }, + }, + ResponseHeaders: &HTTPHeaderModifiers{ + Remove: []string{"x-foo"}, + }, }, }, }, diff --git a/test/integration/connect/envoy/case-cfg-router-features/config_entries.hcl b/test/integration/connect/envoy/case-cfg-router-features/config_entries.hcl index da8b8c751..5eaafeda9 100644 --- a/test/integration/connect/envoy/case-cfg-router-features/config_entries.hcl +++ b/test/integration/connect/envoy/case-cfg-router-features/config_entries.hcl @@ -291,6 +291,36 @@ config_entries { prefix_rewrite = "/debug" } }, + { + match { http { + path_exact = "/header-manip/debug" + } }, + destination { + service_subset = "v2" + prefix_rewrite = "/debug" + request_headers { + set { + x-foo = "request-bar" + } + remove = ["x-bad-req"] + } + } + }, + { + match { http { + path_exact = "/header-manip/echo" + } }, + destination { + service_subset = "v2" + prefix_rewrite = "/" + response_headers { + add { + x-foo = "response-bar" + } + remove = ["x-bad-resp"] + } + } + }, ] } } diff --git a/test/integration/connect/envoy/case-cfg-router-features/verify.bats b/test/integration/connect/envoy/case-cfg-router-features/verify.bats index 15457a7d0..7af248f3c 100644 --- a/test/integration/connect/envoy/case-cfg-router-features/verify.bats +++ b/test/integration/connect/envoy/case-cfg-router-features/verify.bats @@ -104,3 +104,44 @@ load helpers @test "test method match" { assert_expected_fortio_name s2-v2 localhost 5000 /method-match } + +@test "test request header manipulation" { + run retry_default curl -s -f \ + -H "X-Bad-Req: true" \ + "localhost:5000/header-manip/debug?env=dump" + + echo "GOT: $output" + + [ "$status" == "0" ] + + # Should have been routed to the right server + echo "$output" | grep -E "^FORTIO_NAME=s2-v2" + + # Route should have added the right request header + echo "$output" | grep -E "^X-Foo: request-bar" + + # Route should have removed the bad request header + if echo "$output" | grep -E "^X-Bad-Req: true"; then + echo "X-Bad-Req request header should have been stripped but was still present" + exit 1 + fi +} + +@test "test response header manipulation" { + # Add a response header that should be stripped by the route. + run retry_default curl -v -f -X PUT \ + "localhost:5000/header-manip/echo?header=x-bad-resp:true" + + echo "GOT: $output" + + [ "$status" == "0" ] + + # Route should have added the right response header (this is output by curl -v) + echo "$output" | grep -E "^< x-foo: response-bar" + + # Route should have removed the bad response header + if echo "$output" | grep -E "^< x-bad-resp: true"; then + echo "X-Bad-Resp response header should have been stripped but was still present" + exit 1 + fi +} diff --git a/test/integration/connect/envoy/case-cfg-splitter-features/config_entries.hcl b/test/integration/connect/envoy/case-cfg-splitter-features/config_entries.hcl index c95c2f1c8..1ea93fb5f 100644 --- a/test/integration/connect/envoy/case-cfg-splitter-features/config_entries.hcl +++ b/test/integration/connect/envoy/case-cfg-splitter-features/config_entries.hcl @@ -31,10 +31,34 @@ config_entries { { weight = 50, service_subset = "v2" + request_headers { + set { + x-split-leg = "v2" + } + remove = ["x-bad-req"] + } + response_headers { + add { + x-svc-version = "v2" + } + remove = ["x-bad-resp"] + } }, { weight = 50, service_subset = "v1" + request_headers { + set { + x-split-leg = "v1" + } + remove = ["x-bad-req"] + } + response_headers { + add { + x-svc-version = "v1" + } + remove = ["x-bad-resp"] + } }, ] } diff --git a/test/integration/connect/envoy/case-cfg-splitter-features/verify.bats b/test/integration/connect/envoy/case-cfg-splitter-features/verify.bats index 4c6dfa4c4..2d0f2832c 100644 --- a/test/integration/connect/envoy/case-cfg-splitter-features/verify.bats +++ b/test/integration/connect/envoy/case-cfg-splitter-features/verify.bats @@ -50,3 +50,48 @@ load helpers @test "s1 upstream should be able to connect to s2-v1 or s2-v2 via upstream s2" { assert_expected_fortio_name_pattern ^FORTIO_NAME=s2-v[12]$ } + +@test "test request header manipulation" { + run retry_default curl -s -f \ + -H "X-Bad-Req: true" \ + "localhost:5000/debug?env=dump" + + + echo "GOT: $output" + + [ "$status" == "0" ] + + # Figure out which version we hit. This will fail the test if the grep can't + # find a match while capturing the v1 or v2 from the server name in VERSION + VERSION=$(echo "$output" | grep -o -E "^FORTIO_NAME=s2-v[12]" | grep -o 'v[12]$') + + # Route should have added the right request header + GOT_HEADER=$(echo "$output" | grep -E "^X-Split-Leg: v[12]" | grep -o 'v[12]$') + + [ "$GOT_HEADER" == "$VERSION" ] + + # Route should have removed the bad request header + if echo "$output" | grep -E "^X-Bad-Req: true"; then + echo "X-Bad-Req request header should have been stripped but was still present" + exit 1 + fi +} + +@test "test response header manipulation" { + # Add a response header that should be stripped by the route. + run retry_default curl -v -f -X PUT \ + "localhost:5000/header-manip/echo?header=x-bad-resp:true" + + echo "GOT: $output" + + [ "$status" == "0" ] + + # Splitter should have added the right response header (this is output by curl -v) + echo "$output" | grep -E "^< x-svc-version: v[12]" + + # Splitter should have removed the bad response header + if echo "$output" | grep -E "^< x-bad-resp: true"; then + echo "X-Bad-Resp response header should have been stripped but was still present" + exit 1 + fi +} diff --git a/test/integration/connect/envoy/case-ingress-gateway-http/config_entries.hcl b/test/integration/connect/envoy/case-ingress-gateway-http/config_entries.hcl index c11c0ecf7..10c939510 100644 --- a/test/integration/connect/envoy/case-ingress-gateway-http/config_entries.hcl +++ b/test/integration/connect/envoy/case-ingress-gateway-http/config_entries.hcl @@ -18,6 +18,27 @@ config_entries { services = [ { name = "router" + request_headers { + add { + x-foo = "bar-req" + x-existing-1 = "appended-req" + } + set { + x-existing-2 = "replaced-req" + x-client-ip = "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" + } + remove = ["x-bad-req"] + } + response_headers { + add { + x-foo = "bar-resp" + x-existing-1 = "appended-resp" + } + set { + x-existing-2 = "replaced-resp" + } + remove = ["x-bad-resp"] + } } ] } 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 51be6cf22..8640d1901 100644 --- a/test/integration/connect/envoy/case-ingress-gateway-http/verify.bats +++ b/test/integration/connect/envoy/case-ingress-gateway-http/verify.bats @@ -38,3 +38,69 @@ load helpers assert_expected_fortio_name s2 router.ingress.consul 9999 /s2 } +@test "test request header manipulation" { + run retry_default curl -s -f \ + -H "Host: router.ingress.consul" \ + -H "X-Existing-1: original" \ + -H "X-Existing-2: original" \ + -H "X-Bad-Req: true" \ + "localhost:9999/s2/debug?env=dump" + + echo "GOT: $output" + + [ "$status" == "0" ] + + # Should have been routed to the right server + echo "$output" | grep -E "^FORTIO_NAME=s2" + + # Ingress should have added the new request header + echo "$output" | grep -E "^X-Foo: bar-req" + + # Ingress should have appended the first existing header - both should be + # present + echo "$output" | grep -E "^X-Existing-1: original,appended-req" + + # Ingress should have replaced the second existing header + echo "$output" | grep -E "^X-Existing-2: replaced-req" + + # Ingress should have set the client ip from dynamic Envoy variable + echo "$output" | grep -E "^X-Client-Ip: 127.0.0.1" + + # Ingress should have removed the bad request header + if echo "$output" | grep -E "^X-Bad-Req: true"; then + echo "X-Bad-Req request header should have been stripped but was still present" + exit 1 + fi +} + +@test "test response header manipulation" { + # Add a response header that should be stripped by the route. + run retry_default curl -v -s -f -X PUT \ + -H "Host: router.ingress.consul" \ + "localhost:9999/s2/echo?header=x-bad-resp:true&header=x-existing-1:original&header=x-existing-2:original" + + echo "GOT: $output" + + [ "$status" == "0" ] + + # Ingress should have added the new response header + echo "$output" | grep -E "^< x-foo: bar-resp" + + # Ingress should have appended the first existing header - both should be + # present + echo "$output" | grep -E "^< x-existing-1: original" + echo "$output" | grep -E "^< x-existing-1: appended-resp" + + # Ingress should have replaced the second existing header + echo "$output" | grep -E "^< x-existing-2: replaced-resp" + if echo "$output" | grep -E "^< x-existing-2: original"; then + echo "x-existing-2 response header should have been overridden, original still present" + exit 1 + fi + + # Ingress should have removed the bad response header + if echo "$output" | grep -E "^< x-bad-resp: true"; then + echo "X-Bad-Resp response header should have been stripped but was still present" + exit 1 + fi +}