| // Copyright Istio Authors |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package simulation |
| |
| import ( |
| "errors" |
| "fmt" |
| "net" |
| "net/http" |
| "regexp" |
| "strings" |
| "testing" |
| ) |
| |
| import ( |
| cluster "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" |
| core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" |
| listener "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" |
| route "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" |
| tls "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" |
| "github.com/google/go-cmp/cmp" |
| "github.com/google/go-cmp/cmp/cmpopts" |
| "github.com/yl2chen/cidranger" |
| istiolog "istio.io/pkg/log" |
| ) |
| |
| import ( |
| "github.com/apache/dubbo-go-pixiu/pilot/pkg/model" |
| "github.com/apache/dubbo-go-pixiu/pilot/pkg/networking/core/v1alpha3" |
| "github.com/apache/dubbo-go-pixiu/pilot/pkg/xds" |
| xdsfilters "github.com/apache/dubbo-go-pixiu/pilot/pkg/xds/filters" |
| "github.com/apache/dubbo-go-pixiu/pilot/test/xdstest" |
| "github.com/apache/dubbo-go-pixiu/pkg/config/host" |
| "github.com/apache/dubbo-go-pixiu/pkg/test" |
| "github.com/apache/dubbo-go-pixiu/pkg/util/sets" |
| ) |
| |
| var log = istiolog.RegisterScope("simulation", "", 0) |
| |
| type Protocol string |
| |
| const ( |
| HTTP Protocol = "http" |
| HTTP2 Protocol = "http2" |
| TCP Protocol = "tcp" |
| ) |
| |
| type TLSMode string |
| |
| const ( |
| Plaintext TLSMode = "plaintext" |
| TLS TLSMode = "tls" |
| MTLS TLSMode = "mtls" |
| ) |
| |
| func (c Call) IsHTTP() bool { |
| return httpProtocols.Contains(string(c.Protocol)) && (c.TLS == Plaintext || c.TLS == "") |
| } |
| |
| var httpProtocols = sets.New(string(HTTP), string(HTTP2)) |
| |
| var ( |
| ErrNoListener = errors.New("no listener matched") |
| ErrNoFilterChain = errors.New("no filter chains matched") |
| ErrNoRoute = errors.New("no route matched") |
| ErrTLSRedirect = errors.New("tls required, sending 301") |
| ErrNoVirtualHost = errors.New("no virtual host matched") |
| ErrMultipleFilterChain = errors.New("multiple filter chains matched") |
| // ErrProtocolError happens when sending TLS/TCP request to HCM, for example |
| ErrProtocolError = errors.New("protocol error") |
| ErrTLSError = errors.New("invalid TLS") |
| ErrMTLSError = errors.New("invalid mTLS") |
| ) |
| |
| type Expect struct { |
| Name string |
| Call Call |
| Result Result |
| } |
| |
| type CallMode string |
| |
| type CustomFilterChainValidation func(filterChain *listener.FilterChain) error |
| |
| var ( |
| // CallModeGateway simulate no iptables |
| CallModeGateway CallMode = "gateway" |
| // CallModeOutbound simulate iptables redirect to 15001 |
| CallModeOutbound CallMode = "outbound" |
| // CallModeInbound simulate iptables redirect to 15006 |
| CallModeInbound CallMode = "inbound" |
| ) |
| |
| type Call struct { |
| Address string |
| Port int |
| Path string |
| |
| // Protocol describes the protocol type. TLS encapsulation is separate |
| Protocol Protocol |
| // TLS describes the connection tls parameters |
| // TODO: currently this does not verify TLS vs mTLS |
| TLS TLSMode |
| Alpn string |
| |
| // HostHeader is a convenience field for Headers |
| HostHeader string |
| Headers http.Header |
| |
| Sni string |
| |
| // CallMode describes the type of call to make. |
| CallMode CallMode |
| |
| CustomListenerValidations []CustomFilterChainValidation |
| |
| MtlsSecretConfigName string |
| } |
| |
| func (c Call) FillDefaults() Call { |
| if c.Headers == nil { |
| c.Headers = http.Header{} |
| } |
| if c.HostHeader != "" { |
| c.Headers["Host"] = []string{c.HostHeader} |
| } |
| // For simplicity, set SNI automatically for TLS traffic. |
| if c.Sni == "" && (c.TLS == TLS) { |
| c.Sni = c.HostHeader |
| } |
| if c.Path == "" { |
| c.Path = "/" |
| } |
| if c.TLS == "" { |
| c.TLS = Plaintext |
| } |
| if c.Address == "" { |
| // pick a random address, assumption is the test does not care |
| c.Address = "1.3.3.7" |
| } |
| if c.TLS == MTLS && c.Alpn == "" { |
| c.Alpn = protocolToMTLSAlpn(c.Protocol) |
| } |
| if c.TLS == TLS && c.Alpn == "" { |
| c.Alpn = protocolToTLSAlpn(c.Protocol) |
| } |
| return c |
| } |
| |
| type Result struct { |
| Error error |
| ListenerMatched string |
| FilterChainMatched string |
| RouteMatched string |
| RouteConfigMatched string |
| VirtualHostMatched string |
| ClusterMatched string |
| // StrictMatch controls whether we will strictly match the result. If unset, empty fields will |
| // be ignored, allowing testing only fields we care about This allows asserting that the result |
| // is *exactly* equal, allowing asserting a field is empty |
| StrictMatch bool |
| // If set, this will mark a test as skipped. Note the result is still checked first - we skip only |
| // if we pass the test. This is to ensure that if the behavior changes, we still capture it; the skip |
| // just ensures we notice a test is wrong |
| Skip string |
| t test.Failer |
| } |
| |
| func (r Result) Matches(t *testing.T, want Result) { |
| t.Helper() |
| r.StrictMatch = want.StrictMatch // to make diff pass |
| r.Skip = want.Skip // to make diff pass |
| diff := cmp.Diff(want, r, cmpopts.IgnoreUnexported(Result{}), cmpopts.EquateErrors()) |
| if want.StrictMatch && diff != "" { |
| t.Errorf("Diff: %v", diff) |
| return |
| } |
| if want.Error != r.Error { |
| t.Errorf("want error %v got %v", want.Error, r.Error) |
| } |
| if want.ListenerMatched != "" && want.ListenerMatched != r.ListenerMatched { |
| t.Errorf("want listener matched %q got %q", want.ListenerMatched, r.ListenerMatched) |
| } else { |
| // Populate each field in case we did not care about it. This avoids confusing errors when we have fields |
| // we don't care about in the test that are present in the result. |
| want.ListenerMatched = r.ListenerMatched |
| } |
| if want.FilterChainMatched != "" && want.FilterChainMatched != r.FilterChainMatched { |
| t.Errorf("want filter chain matched %q got %q", want.FilterChainMatched, r.FilterChainMatched) |
| } else { |
| want.FilterChainMatched = r.FilterChainMatched |
| } |
| if want.RouteMatched != "" && want.RouteMatched != r.RouteMatched { |
| t.Errorf("want route matched %q got %q", want.RouteMatched, r.RouteMatched) |
| } else { |
| want.RouteMatched = r.RouteMatched |
| } |
| if want.RouteConfigMatched != "" && want.RouteConfigMatched != r.RouteConfigMatched { |
| t.Errorf("want route config matched %q got %q", want.RouteConfigMatched, r.RouteConfigMatched) |
| } else { |
| want.RouteConfigMatched = r.RouteConfigMatched |
| } |
| if want.VirtualHostMatched != "" && want.VirtualHostMatched != r.VirtualHostMatched { |
| t.Errorf("want virtual host matched %q got %q", want.VirtualHostMatched, r.VirtualHostMatched) |
| } else { |
| want.VirtualHostMatched = r.VirtualHostMatched |
| } |
| if want.ClusterMatched != "" && want.ClusterMatched != r.ClusterMatched { |
| t.Errorf("want cluster matched %q got %q", want.ClusterMatched, r.ClusterMatched) |
| } else { |
| want.ClusterMatched = r.ClusterMatched |
| } |
| if t.Failed() { |
| t.Logf("Diff: %+v", diff) |
| t.Logf("Full Diff: %+v", cmp.Diff(want, r, cmpopts.IgnoreUnexported(Result{}), cmpopts.EquateErrors())) |
| } else if want.Skip != "" { |
| t.Skip(fmt.Sprintf("Known bug: %v", r.Skip)) |
| } |
| } |
| |
| type Simulation struct { |
| t *testing.T |
| Listeners []*listener.Listener |
| Clusters []*cluster.Cluster |
| Routes []*route.RouteConfiguration |
| } |
| |
| func NewSimulationFromConfigGen(t *testing.T, s *v1alpha3.ConfigGenTest, proxy *model.Proxy) *Simulation { |
| l := s.Listeners(proxy) |
| sim := &Simulation{ |
| t: t, |
| Listeners: l, |
| Clusters: s.Clusters(proxy), |
| Routes: s.RoutesFromListeners(proxy, l), |
| } |
| return sim |
| } |
| |
| func NewSimulation(t *testing.T, s *xds.FakeDiscoveryServer, proxy *model.Proxy) *Simulation { |
| return NewSimulationFromConfigGen(t, s.ConfigGenTest, proxy) |
| } |
| |
| // withT swaps out the testing struct. This allows executing sub tests. |
| func (sim *Simulation) withT(t *testing.T) *Simulation { |
| cpy := *sim |
| cpy.t = t |
| return &cpy |
| } |
| |
| func (sim *Simulation) RunExpectations(es []Expect) { |
| for _, e := range es { |
| sim.t.Run(e.Name, func(t *testing.T) { |
| sim.withT(t).Run(e.Call).Matches(t, e.Result) |
| }) |
| } |
| } |
| |
| func hasFilterOnPort(l *listener.Listener, filter string, port int) bool { |
| got, f := xdstest.ExtractListenerFilters(l)[filter] |
| if !f { |
| return false |
| } |
| if got.FilterDisabled == nil { |
| return true |
| } |
| return !xdstest.EvaluateListenerFilterPredicates(got.FilterDisabled, port) |
| } |
| |
| func (sim *Simulation) Run(input Call) (result Result) { |
| result = Result{t: sim.t} |
| input = input.FillDefaults() |
| if input.Alpn != "" && input.TLS == Plaintext { |
| result.Error = fmt.Errorf("invalid call, ALPN can only be sent in TLS requests") |
| return result |
| } |
| |
| // First we will match a listener |
| l := matchListener(sim.Listeners, input) |
| if l == nil { |
| result.Error = ErrNoListener |
| return |
| } |
| result.ListenerMatched = l.Name |
| |
| hasTLSInspector := hasFilterOnPort(l, xdsfilters.TLSInspector.Name, input.Port) |
| if !hasTLSInspector { |
| // Without tls inspector, Envoy would not read the ALPN in the TLS handshake |
| // HTTP inspector still may set it though |
| input.Alpn = "" |
| } |
| |
| // Apply listener filters |
| if hasFilterOnPort(l, xdsfilters.HTTPInspector.Name, input.Port) { |
| if alpn := protocolToAlpn(input.Protocol); alpn != "" && input.TLS == Plaintext { |
| input.Alpn = alpn |
| } |
| } |
| |
| fc, err := sim.matchFilterChain(l.FilterChains, l.DefaultFilterChain, input, hasTLSInspector) |
| if err != nil { |
| result.Error = err |
| return |
| } |
| result.FilterChainMatched = fc.Name |
| // Plaintext to TLS is an error |
| if fc.TransportSocket != nil && input.TLS == Plaintext { |
| result.Error = ErrTLSError |
| return |
| } |
| |
| mTLSSecretConfigName := "default" |
| if input.MtlsSecretConfigName != "" { |
| mTLSSecretConfigName = input.MtlsSecretConfigName |
| } |
| |
| // mTLS listener will only accept mTLS traffic |
| if fc.TransportSocket != nil && sim.requiresMTLS(fc, mTLSSecretConfigName) != (input.TLS == MTLS) { |
| // If there is no tls inspector, then |
| result.Error = ErrMTLSError |
| return |
| } |
| |
| if len(input.CustomListenerValidations) > 0 { |
| for _, validation := range input.CustomListenerValidations { |
| if err := validation(fc); err != nil { |
| result.Error = err |
| } |
| } |
| } |
| |
| if hcm := xdstest.ExtractHTTPConnectionManager(sim.t, fc); hcm != nil { |
| // We matched HCM and didn't terminate TLS, but we are sending TLS traffic - decoding will fail |
| if input.TLS != Plaintext && fc.TransportSocket == nil { |
| result.Error = ErrProtocolError |
| return |
| } |
| // TCP to HCM is invalid |
| if input.Protocol != HTTP && input.Protocol != HTTP2 { |
| result.Error = ErrProtocolError |
| return |
| } |
| |
| // Fetch inline route |
| rc := hcm.GetRouteConfig() |
| if rc == nil { |
| // If not set, fallback to RDS |
| routeName := hcm.GetRds().RouteConfigName |
| result.RouteConfigMatched = routeName |
| rc = xdstest.ExtractRouteConfigurations(sim.Routes)[routeName] |
| } |
| hostHeader := "" |
| if len(input.Headers["Host"]) > 0 { |
| hostHeader = input.Headers["Host"][0] |
| } |
| vh := sim.matchVirtualHost(rc, hostHeader) |
| if vh == nil { |
| result.Error = ErrNoVirtualHost |
| return |
| } |
| result.VirtualHostMatched = vh.Name |
| if vh.RequireTls == route.VirtualHost_ALL && input.TLS == Plaintext { |
| result.Error = ErrTLSRedirect |
| return |
| } |
| |
| r := sim.matchRoute(vh, input) |
| if r == nil { |
| result.Error = ErrNoRoute |
| return |
| } |
| result.RouteMatched = r.Name |
| switch t := r.GetAction().(type) { |
| case *route.Route_Route: |
| result.ClusterMatched = t.Route.GetCluster() |
| } |
| } else if tcp := xdstest.ExtractTCPProxy(sim.t, fc); tcp != nil { |
| result.ClusterMatched = tcp.GetCluster() |
| } |
| return |
| } |
| |
| func (sim *Simulation) requiresMTLS(fc *listener.FilterChain, mTLSSecretConfigName string) bool { |
| if fc.TransportSocket == nil { |
| return false |
| } |
| t := &tls.DownstreamTlsContext{} |
| if err := fc.GetTransportSocket().GetTypedConfig().UnmarshalTo(t); err != nil { |
| sim.t.Fatal(err) |
| } |
| |
| if len(t.GetCommonTlsContext().GetTlsCertificateSdsSecretConfigs()) == 0 { |
| return false |
| } |
| // This is a lazy heuristic, we could check for explicit default resource or spiffe if it becomes necessary |
| if t.GetCommonTlsContext().GetTlsCertificateSdsSecretConfigs()[0].Name != mTLSSecretConfigName { |
| return false |
| } |
| if !t.RequireClientCertificate.Value { |
| return false |
| } |
| return true |
| } |
| |
| func (sim *Simulation) matchRoute(vh *route.VirtualHost, input Call) *route.Route { |
| for _, r := range vh.Routes { |
| // check path |
| switch pt := r.Match.GetPathSpecifier().(type) { |
| case *route.RouteMatch_Prefix: |
| if !strings.HasPrefix(input.Path, pt.Prefix) { |
| continue |
| } |
| case *route.RouteMatch_Path: |
| if input.Path != pt.Path { |
| continue |
| } |
| case *route.RouteMatch_SafeRegex: |
| r, err := regexp.Compile(pt.SafeRegex.GetRegex()) |
| if err != nil { |
| sim.t.Fatalf("invalid regex %v: %v", pt.SafeRegex.GetRegex(), err) |
| } |
| if !r.MatchString(input.Path) { |
| continue |
| } |
| default: |
| sim.t.Fatalf("unknown route path type") |
| } |
| |
| // TODO this only handles path - we need to add headers, query params, etc to be complete. |
| |
| return r |
| } |
| return nil |
| } |
| |
| func (sim *Simulation) matchVirtualHost(rc *route.RouteConfiguration, host string) *route.VirtualHost { |
| // Exact match |
| for _, vh := range rc.VirtualHosts { |
| for _, d := range vh.Domains { |
| if d == host { |
| return vh |
| } |
| } |
| } |
| // prefix match |
| var bestMatch *route.VirtualHost |
| longest := 0 |
| for _, vh := range rc.VirtualHosts { |
| for _, d := range vh.Domains { |
| if d[0] != '*' { |
| continue |
| } |
| if len(host) >= len(d) && strings.HasSuffix(host, d[1:]) && len(d) > longest { |
| bestMatch = vh |
| longest = len(d) |
| } |
| } |
| } |
| if bestMatch != nil { |
| return bestMatch |
| } |
| // Suffix match |
| longest = 0 |
| for _, vh := range rc.VirtualHosts { |
| for _, d := range vh.Domains { |
| if d[len(d)-1] != '*' { |
| continue |
| } |
| if len(host) >= len(d) && strings.HasPrefix(host, d[:len(d)-1]) && len(d) > longest { |
| bestMatch = vh |
| longest = len(d) |
| } |
| } |
| } |
| if bestMatch != nil { |
| return bestMatch |
| } |
| // wildcard match |
| for _, vh := range rc.VirtualHosts { |
| for _, d := range vh.Domains { |
| if d == "*" { |
| return vh |
| } |
| } |
| } |
| return nil |
| } |
| |
| // Follow the 8 step Sieve as in |
| // https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/listener/v3/listener_components.proto.html#config-listener-v3-filterchainmatch |
| // The implementation may initially be confusing because of a property of the |
| // Envoy algorithm - at each level we will filter out all FilterChains that do |
| // not match. This means an empty match (`{}`) may not match if another chain |
| // matches one criteria but not another. |
| func (sim *Simulation) matchFilterChain(chains []*listener.FilterChain, defaultChain *listener.FilterChain, |
| input Call, hasTLSInspector bool) (*listener.FilterChain, error) { |
| chains = filter("DestinationPort", chains, func(fc *listener.FilterChainMatch) bool { |
| return fc.GetDestinationPort() == nil |
| }, func(fc *listener.FilterChainMatch) bool { |
| return int(fc.GetDestinationPort().GetValue()) == input.Port |
| }) |
| chains = filter("PrefixRanges", chains, func(fc *listener.FilterChainMatch) bool { |
| return fc.GetPrefixRanges() == nil |
| }, func(fc *listener.FilterChainMatch) bool { |
| ranger := cidranger.NewPCTrieRanger() |
| for _, a := range fc.GetPrefixRanges() { |
| s := fmt.Sprintf("%s/%d", a.AddressPrefix, a.GetPrefixLen().GetValue()) |
| _, cidr, err := net.ParseCIDR(s) |
| if err != nil { |
| sim.t.Fatalf("failed to parse cidr %v: %v", s, err) |
| } |
| if err := ranger.Insert(cidranger.NewBasicRangerEntry(*cidr)); err != nil { |
| sim.t.Fatalf("failed to insert cidr %v: %v", cidr, err) |
| } |
| } |
| f, err := ranger.Contains(net.ParseIP(input.Address)) |
| if err != nil { |
| sim.t.Fatalf("cidr containers %v failed: %v", input.Address, err) |
| } |
| return f |
| }) |
| chains = filter("ServerNames", chains, func(fc *listener.FilterChainMatch) bool { |
| return fc.GetServerNames() == nil |
| }, func(fc *listener.FilterChainMatch) bool { |
| sni := host.Name(input.Sni) |
| for _, s := range fc.GetServerNames() { |
| if sni.SubsetOf(host.Name(s)) { |
| return true |
| } |
| } |
| return false |
| }) |
| chains = filter("TransportProtocol", chains, func(fc *listener.FilterChainMatch) bool { |
| return fc.GetTransportProtocol() == "" |
| }, func(fc *listener.FilterChainMatch) bool { |
| if !hasTLSInspector { |
| // Without tls inspector, transport protocol will always be raw buffer |
| return fc.GetTransportProtocol() == xdsfilters.RawBufferTransportProtocol |
| } |
| switch fc.GetTransportProtocol() { |
| case xdsfilters.TLSTransportProtocol: |
| return input.TLS == TLS || input.TLS == MTLS |
| case xdsfilters.RawBufferTransportProtocol: |
| return input.TLS == Plaintext |
| } |
| return false |
| }) |
| chains = filter("ApplicationProtocols", chains, func(fc *listener.FilterChainMatch) bool { |
| return fc.GetApplicationProtocols() == nil |
| }, func(fc *listener.FilterChainMatch) bool { |
| return sets.New(fc.GetApplicationProtocols()...).Contains(input.Alpn) |
| }) |
| // We do not implement the "source" based filters as we do not use them |
| if len(chains) > 1 { |
| for _, c := range chains { |
| log.Warnf("Matched chain %v", c.Name) |
| } |
| return nil, ErrMultipleFilterChain |
| } |
| if len(chains) == 0 { |
| if defaultChain != nil { |
| return defaultChain, nil |
| } |
| return nil, ErrNoFilterChain |
| } |
| return chains[0], nil |
| } |
| |
| func filter(desc string, chains []*listener.FilterChain, |
| empty func(fc *listener.FilterChainMatch) bool, |
| match func(fc *listener.FilterChainMatch) bool) []*listener.FilterChain { |
| res := []*listener.FilterChain{} |
| anySet := false |
| for _, c := range chains { |
| if !empty(c.GetFilterChainMatch()) { |
| anySet = true |
| break |
| } |
| } |
| if !anySet { |
| log.Debugf("%v: none set, skipping", desc) |
| return chains |
| } |
| for i, c := range chains { |
| if match(c.GetFilterChainMatch()) { |
| log.Debugf("%v: matched chain %v/%v", desc, i, c.GetName()) |
| res = append(res, c) |
| } |
| } |
| // Return all matching filter chains |
| if len(res) > 0 { |
| return res |
| } |
| // Unless there were no matches - in which case we return all filter chains that did not have a |
| // match set |
| for i, c := range chains { |
| if empty(c.GetFilterChainMatch()) { |
| log.Debugf("%v: no matches, found empty chain match %v/%v", desc, i, c.GetName()) |
| res = append(res, c) |
| } |
| } |
| return res |
| } |
| |
| func protocolToMTLSAlpn(s Protocol) string { |
| switch s { |
| case HTTP: |
| return "istio-http/1.1" |
| case HTTP2: |
| return "istio-h2" |
| default: |
| return "istio" |
| } |
| } |
| |
| func protocolToTLSAlpn(s Protocol) string { |
| switch s { |
| case HTTP: |
| return "http/1.1" |
| case HTTP2: |
| return "h2" |
| default: |
| return "" |
| } |
| } |
| |
| func protocolToAlpn(s Protocol) string { |
| switch s { |
| case HTTP: |
| return "http/1.1" |
| case HTTP2: |
| return "h2c" |
| default: |
| return "" |
| } |
| } |
| |
| func matchListener(listeners []*listener.Listener, input Call) *listener.Listener { |
| if input.CallMode == CallModeInbound { |
| return xdstest.ExtractListener(model.VirtualInboundListenerName, listeners) |
| } |
| // First find exact match for the IP/Port, then fallback to wildcard IP/Port |
| // There is no wildcard port |
| for _, l := range listeners { |
| if matchAddress(l.GetAddress(), input.Address, input.Port) { |
| return l |
| } |
| } |
| for _, l := range listeners { |
| if matchAddress(l.GetAddress(), "0.0.0.0", input.Port) { |
| return l |
| } |
| } |
| |
| // Fallback to the outbound listener |
| // TODO - support inbound |
| for _, l := range listeners { |
| if l.Name == model.VirtualOutboundListenerName { |
| return l |
| } |
| } |
| return nil |
| } |
| |
| func matchAddress(a *core.Address, address string, port int) bool { |
| if a.GetSocketAddress().GetAddress() != address { |
| return false |
| } |
| if int(a.GetSocketAddress().GetPortValue()) != port { |
| return false |
| } |
| return true |
| } |