diff --git a/.changelog/10016.txt b/.changelog/10016.txt new file mode 100644 index 0000000000..7b309db101 --- /dev/null +++ b/.changelog/10016.txt @@ -0,0 +1,3 @@ +```release-note:improvement +connect: Update the service mesh visualization to account for transparent proxies. +``` \ No newline at end of file diff --git a/agent/consul/helper_test.go b/agent/consul/helper_test.go index 0b44f87f33..2db49f667b 100644 --- a/agent/consul/helper_test.go +++ b/agent/consul/helper_test.go @@ -573,11 +573,50 @@ func registerTestCatalogEntriesMap(t *testing.T, codec rpc.ClientCodec, registra func registerTestTopologyEntries(t *testing.T, codec rpc.ClientCodec, token string) { t.Helper() - // api and api-proxy on node foo - upstream: web + // ingress-gateway on node edge - upstream: api + // api and api-proxy on node foo - transparent proxy // web and web-proxy on node bar - upstream: redis - // web and web-proxy on node baz - upstream: redis + // web and web-proxy on node baz - transparent proxy // redis and redis-proxy on node zip registrations := map[string]*structs.RegisterRequest{ + "Node edge": { + Datacenter: "dc1", + Node: "edge", + ID: types.NodeID("8e3481c0-760e-4b5f-a3b8-6c8c559e8a15"), + Address: "127.0.0.1", + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "edge", + CheckID: "edge:alive", + Name: "edge-liveness", + Status: api.HealthPassing, + }, + }, + WriteRequest: structs.WriteRequest{Token: token}, + }, + "Service ingress on edge": { + Datacenter: "dc1", + Node: "edge", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindIngressGateway, + ID: "ingress", + Service: "ingress", + Port: 8443, + Address: "198.18.1.1", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "edge", + CheckID: "edge:ingress", + Name: "ingress-liveness", + Status: api.HealthPassing, + ServiceID: "ingress", + ServiceName: "ingress", + }, + }, + WriteRequest: structs.WriteRequest{Token: token}, + }, "Node foo": { Datacenter: "dc1", Node: "foo", @@ -627,13 +666,8 @@ func registerTestTopologyEntries(t *testing.T, codec rpc.ClientCodec, token stri Port: 8443, Address: "198.18.1.2", Proxy: structs.ConnectProxyConfig{ + Mode: structs.ProxyModeTransparent, DestinationServiceName: "api", - Upstreams: structs.Upstreams{ - { - DestinationName: "web", - LocalBindPort: 8080, - }, - }, }, }, Checks: structs.HealthChecks{ @@ -767,13 +801,8 @@ func registerTestTopologyEntries(t *testing.T, codec rpc.ClientCodec, token stri Port: 8443, Address: "198.18.1.40", Proxy: structs.ConnectProxyConfig{ + Mode: structs.ProxyModeTransparent, DestinationServiceName: "web", - Upstreams: structs.Upstreams{ - { - DestinationName: "redis", - LocalBindPort: 123, - }, - }, }, }, Checks: structs.HealthChecks{ @@ -855,7 +884,10 @@ func registerTestTopologyEntries(t *testing.T, codec rpc.ClientCodec, token stri } registerTestCatalogEntriesMap(t, codec, registrations) - // Add intentions: deny all, web -> redis with L7 perms, but omit intention for api -> web + // ingress -> api gateway config entry (but no intention) + // wildcard deny intention + // api -> web exact intention + // web -> redis exact intention entries := []structs.ConfigEntryRequest{ { Datacenter: "dc1", @@ -868,6 +900,39 @@ func registerTestTopologyEntries(t *testing.T, codec rpc.ClientCodec, token stri }, WriteRequest: structs.WriteRequest{Token: token}, }, + { + Datacenter: "dc1", + Entry: &structs.IngressGatewayConfigEntry{ + Kind: structs.IngressGateway, + Name: "ingress", + Listeners: []structs.IngressListener{ + { + Port: 8443, + Protocol: "http", + Services: []structs.IngressService{ + { + Name: "api", + }, + }, + }, + }, + }, + WriteRequest: structs.WriteRequest{Token: token}, + }, + { + Datacenter: "dc1", + Entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "web", + Sources: []*structs.SourceIntention{ + { + Action: structs.IntentionActionAllow, + Name: "api", + }, + }, + }, + WriteRequest: structs.WriteRequest{Token: token}, + }, { Datacenter: "dc1", Entry: &structs.ServiceIntentionsConfigEntry{ diff --git a/agent/consul/internal_endpoint_test.go b/agent/consul/internal_endpoint_test.go index f845da827f..f8baf083a9 100644 --- a/agent/consul/internal_endpoint_test.go +++ b/agent/consul/internal_endpoint_test.go @@ -1690,20 +1690,67 @@ func TestInternal_ServiceTopology(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - // api and api-proxy on node foo - upstream: web - // web and web-proxy on node bar - upstream: redis - // web and web-proxy on node baz - upstream: redis - // redis and redis-proxy on node zip // wildcard deny intention - // web -> redis exact intentino + // ingress-gateway on node edge - upstream: api + // ingress -> api gateway config entry (but no intention) + + // api and api-proxy on node foo - transparent proxy + // api -> web exact intention + + // web and web-proxy on node bar - upstream: redis + // web and web-proxy on node baz - transparent proxy + // web -> redis exact intention + + // redis and redis-proxy on node zip + registerTestTopologyEntries(t, codec, "") var ( - api = structs.NewServiceName("api", structs.DefaultEnterpriseMeta()) - web = structs.NewServiceName("web", structs.DefaultEnterpriseMeta()) - redis = structs.NewServiceName("redis", structs.DefaultEnterpriseMeta()) + ingress = structs.NewServiceName("ingress", structs.DefaultEnterpriseMeta()) + api = structs.NewServiceName("api", structs.DefaultEnterpriseMeta()) + web = structs.NewServiceName("web", structs.DefaultEnterpriseMeta()) + redis = structs.NewServiceName("redis", structs.DefaultEnterpriseMeta()) ) + t.Run("ingress", func(t *testing.T) { + retry.Run(t, func(r *retry.R) { + args := structs.ServiceSpecificRequest{ + Datacenter: "dc1", + ServiceName: "ingress", + } + var out structs.IndexedServiceTopology + require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out)) + require.False(r, out.FilteredByACLs) + require.Equal(r, "http", out.ServiceTopology.MetricsProtocol) + + // foo/api, foo/api-proxy + require.Len(r, out.ServiceTopology.Upstreams, 2) + require.Len(r, out.ServiceTopology.Downstreams, 0) + + expectUp := map[string]structs.IntentionDecisionSummary{ + api.String(): { + DefaultAllow: true, + Allowed: false, + HasPermissions: false, + ExternalSource: "nomad", + + // From wildcard deny + HasExact: false, + }, + } + require.Equal(r, expectUp, out.ServiceTopology.UpstreamDecisions) + + expectUpstreamSources := map[string]string{ + api.String(): structs.TopologySourceRegistration, + } + require.Equal(r, expectUpstreamSources, out.ServiceTopology.UpstreamSources) + require.Empty(r, out.ServiceTopology.DownstreamSources) + + // The ingress gateway has an explicit upstream + require.False(r, out.ServiceTopology.TransparentProxy) + }) + }) + t.Run("api", func(t *testing.T) { retry.Run(t, func(r *retry.R) { args := structs.ServiceSpecificRequest{ @@ -1715,12 +1762,12 @@ func TestInternal_ServiceTopology(t *testing.T) { require.False(r, out.FilteredByACLs) require.Equal(r, "http", out.ServiceTopology.MetricsProtocol) - // bar/web, bar/web-proxy, baz/web, baz/web-proxy - require.Len(r, out.ServiceTopology.Upstreams, 4) - require.Len(r, out.ServiceTopology.Downstreams, 0) + // edge/ingress + require.Len(r, out.ServiceTopology.Downstreams, 1) - expectUp := map[string]structs.IntentionDecisionSummary{ - web.String(): { + expectDown := map[string]structs.IntentionDecisionSummary{ + ingress.String(): { + DefaultAllow: true, Allowed: false, HasPermissions: false, ExternalSource: "nomad", @@ -1729,7 +1776,33 @@ func TestInternal_ServiceTopology(t *testing.T) { HasExact: false, }, } + require.Equal(r, expectDown, out.ServiceTopology.DownstreamDecisions) + + expectDownstreamSources := map[string]string{ + ingress.String(): structs.TopologySourceRegistration, + } + require.Equal(r, expectDownstreamSources, out.ServiceTopology.DownstreamSources) + + // bar/web, bar/web-proxy, baz/web, baz/web-proxy + require.Len(r, out.ServiceTopology.Upstreams, 4) + + expectUp := map[string]structs.IntentionDecisionSummary{ + web.String(): { + DefaultAllow: true, + Allowed: true, + HasPermissions: false, + HasExact: true, + }, + } require.Equal(r, expectUp, out.ServiceTopology.UpstreamDecisions) + + expectUpstreamSources := map[string]string{ + web.String(): structs.TopologySourceSpecificIntention, + } + require.Equal(r, expectUpstreamSources, out.ServiceTopology.UpstreamSources) + + // The only instance of api's proxy is in transparent mode + require.True(r, out.ServiceTopology.TransparentProxy) }) }) @@ -1749,27 +1822,40 @@ func TestInternal_ServiceTopology(t *testing.T) { expectDown := map[string]structs.IntentionDecisionSummary{ api.String(): { - Allowed: false, + DefaultAllow: true, + Allowed: true, HasPermissions: false, - ExternalSource: "nomad", - - // From wildcard deny - HasExact: false, + HasExact: true, }, } require.Equal(r, expectDown, out.ServiceTopology.DownstreamDecisions) + expectDownstreamSources := map[string]string{ + api.String(): structs.TopologySourceSpecificIntention, + } + require.Equal(r, expectDownstreamSources, out.ServiceTopology.DownstreamSources) + // zip/redis, zip/redis-proxy require.Len(r, out.ServiceTopology.Upstreams, 2) expectUp := map[string]structs.IntentionDecisionSummary{ redis.String(): { + DefaultAllow: true, Allowed: false, HasPermissions: true, HasExact: true, }, } require.Equal(r, expectUp, out.ServiceTopology.UpstreamDecisions) + + expectUpstreamSources := map[string]string{ + // We prefer from-registration over intention source when there is a mix + redis.String(): structs.TopologySourceRegistration, + } + require.Equal(r, expectUpstreamSources, out.ServiceTopology.UpstreamSources) + + // Not all instances of web are in transparent mode + require.False(r, out.ServiceTopology.TransparentProxy) }) }) @@ -1791,12 +1877,22 @@ func TestInternal_ServiceTopology(t *testing.T) { expectDown := map[string]structs.IntentionDecisionSummary{ web.String(): { + DefaultAllow: true, Allowed: false, HasPermissions: true, HasExact: true, }, } require.Equal(r, expectDown, out.ServiceTopology.DownstreamDecisions) + + expectDownstreamSources := map[string]string{ + web.String(): structs.TopologySourceRegistration, + } + require.Equal(r, expectDownstreamSources, out.ServiceTopology.DownstreamSources) + require.Empty(r, out.ServiceTopology.UpstreamSources) + + // No proxies are in transparent mode + require.False(r, out.ServiceTopology.TransparentProxy) }) }) } @@ -1821,9 +1917,17 @@ func TestInternal_ServiceTopology_ACL(t *testing.T) { codec := rpcClient(t, s1) defer codec.Close() - // api and api-proxy on node foo - upstream: web + // wildcard deny intention + // ingress-gateway on node edge - upstream: api + // ingress -> api gateway config entry (but no intention) + + // api and api-proxy on node foo - transparent proxy + // api -> web exact intention + // web and web-proxy on node bar - upstream: redis - // web and web-proxy on node baz - upstream: redis + // web and web-proxy on node baz - transparent proxy + // web -> redis exact intention + // redis and redis-proxy on node zip registerTestTopologyEntries(t, codec, TestDefaultMasterToken) diff --git a/agent/consul/state/catalog.go b/agent/consul/state/catalog.go index c66d9de409..249c9df27f 100644 --- a/agent/consul/state/catalog.go +++ b/agent/consul/state/catalog.go @@ -2853,6 +2853,8 @@ func checkProtocolMatch(tx ReadTxn, ws memdb.WatchSet, svc *structs.GatewayServi return idx, svc.Protocol == protocol, nil } +// TODO(freddy) Split this up. The upstream/downstream logic is very similar. +// TODO(freddy) Add comprehensive state store test func (s *Store) ServiceTopology( ws memdb.WatchSet, dc, service string, @@ -2863,14 +2865,15 @@ func (s *Store) ServiceTopology( tx := s.db.ReadTxn() defer tx.Abort() + sn := structs.NewServiceName(service, entMeta) + var ( - maxIdx uint64 - protocol string - err error - - sn = structs.NewServiceName(service, entMeta) + maxIdx uint64 + protocol string + err error + fullyTransparent bool + hasTransparent bool ) - switch kind { case structs.ServiceKindIngressGateway: maxIdx, protocol, err = metricsProtocolForIngressGateway(tx, ws, sn) @@ -2884,6 +2887,38 @@ func (s *Store) ServiceTopology( return 0, nil, fmt.Errorf("failed to fetch protocol for service %s: %v", sn.String(), err) } + // Fetch connect endpoints for the target service in order to learn if its proxies are configured as + // transparent proxies. + if entMeta == nil { + entMeta = structs.DefaultEnterpriseMeta() + } + q := Query{Value: service, EnterpriseMeta: *entMeta} + + idx, proxies, err := serviceNodesTxn(tx, ws, indexConnect, q) + if err != nil { + return 0, nil, fmt.Errorf("failed to fetch connect endpoints for service %s: %v", sn.String(), err) + } + if idx > maxIdx { + maxIdx = idx + } + if len(proxies) == 0 { + break + } + + fullyTransparent = true + for _, proxy := range proxies { + switch proxy.ServiceProxy.Mode { + case structs.ProxyModeTransparent: + hasTransparent = true + + default: + // Only consider the target proxy to be transparent when all instances are in that mode. + // This is done because the flag is used to display warnings about proxies needing to enable + // transparent proxy mode. If ANY instance isn't in the right mode then the warming applies. + fullyTransparent = false + } + } + default: return 0, nil, fmt.Errorf("unsupported kind %q", kind) } @@ -2895,7 +2930,48 @@ func (s *Store) ServiceTopology( if idx > maxIdx { maxIdx = idx } - idx, upstreams, err := s.combinedServiceNodesTxn(tx, ws, upstreamNames) + + var ( + seenUpstreams = make(map[string]struct{}) + upstreamSources = make(map[string]string) + ) + for _, un := range upstreamNames { + if _, ok := seenUpstreams[un.String()]; !ok { + seenUpstreams[un.String()] = struct{}{} + } + upstreamSources[un.String()] = structs.TopologySourceRegistration + } + + idx, intentionUpstreams, err := s.intentionTopologyTxn(tx, ws, sn, false, defaultAllow) + if err != nil { + return 0, nil, err + } + if idx > maxIdx { + maxIdx = idx + } + + upstreamDecisions := make(map[string]structs.IntentionDecisionSummary) + for _, svc := range intentionUpstreams { + if _, ok := seenUpstreams[svc.Name.String()]; ok { + // Avoid duplicating entry + continue + } + upstreamDecisions[svc.Name.String()] = svc.Decision + upstreamNames = append(upstreamNames, svc.Name) + + var source string + switch { + case svc.Decision.HasExact: + source = structs.TopologySourceSpecificIntention + case svc.Decision.DefaultAllow: + source = structs.TopologySourceDefaultAllow + default: + source = structs.TopologySourceWildcardIntention + } + upstreamSources[svc.Name.String()] = source + } + + idx, unfilteredUpstreams, err := s.combinedServiceNodesTxn(tx, ws, upstreamNames) if err != nil { return 0, nil, fmt.Errorf("failed to get upstreams for %q: %v", sn.String(), err) } @@ -2903,14 +2979,32 @@ func (s *Store) ServiceTopology( maxIdx = idx } - upstreamDecisions := make(map[string]structs.IntentionDecisionSummary) + var upstreams structs.CheckServiceNodes + for _, upstream := range unfilteredUpstreams { + sn := upstream.Service.CompoundServiceName() + if upstream.Service.Kind == structs.ServiceKindConnectProxy { + sn = structs.NewServiceName(upstream.Service.Proxy.DestinationServiceName, &upstream.Service.EnterpriseMeta) + } + + // Avoid returning upstreams from intentions when none of the proxy instances of the target are in transparent mode. + if !hasTransparent && upstreamSources[sn.String()] != structs.TopologySourceRegistration { + continue + } + upstreams = append(upstreams, upstream) + } matchEntry := structs.IntentionMatchEntry{ Namespace: entMeta.NamespaceOrDefault(), Name: service, } - // The given service is a source relative to its upstreams - _, srcIntentions, err := compatIntentionMatchOneTxn(tx, ws, matchEntry, structs.IntentionMatchSource) + _, srcIntentions, err := compatIntentionMatchOneTxn( + tx, + ws, + matchEntry, + + // The given service is a source relative to its upstreams + structs.IntentionMatchSource, + ) if err != nil { return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String()) } @@ -2930,7 +3024,48 @@ func (s *Store) ServiceTopology( if idx > maxIdx { maxIdx = idx } - idx, downstreams, err := s.combinedServiceNodesTxn(tx, ws, downstreamNames) + + var ( + seenDownstreams = make(map[string]struct{}) + downstreamSources = make(map[string]string) + ) + for _, dn := range downstreamNames { + if _, ok := seenDownstreams[dn.String()]; !ok { + seenDownstreams[dn.String()] = struct{}{} + } + downstreamSources[dn.String()] = structs.TopologySourceRegistration + } + + idx, intentionDownstreams, err := s.intentionTopologyTxn(tx, ws, sn, true, defaultAllow) + if err != nil { + return 0, nil, err + } + if idx > maxIdx { + maxIdx = idx + } + + downstreamDecisions := make(map[string]structs.IntentionDecisionSummary) + for _, svc := range intentionDownstreams { + if _, ok := seenDownstreams[svc.Name.String()]; ok { + // Avoid duplicating entry + continue + } + downstreamNames = append(downstreamNames, svc.Name) + downstreamDecisions[svc.Name.String()] = svc.Decision + + var source string + switch { + case svc.Decision.HasExact: + source = structs.TopologySourceSpecificIntention + case svc.Decision.DefaultAllow: + source = structs.TopologySourceDefaultAllow + default: + source = structs.TopologySourceWildcardIntention + } + downstreamSources[svc.Name.String()] = source + } + + idx, unfilteredDownstreams, err := s.combinedServiceNodesTxn(tx, ws, downstreamNames) if err != nil { return 0, nil, fmt.Errorf("failed to get downstreams for %q: %v", sn.String(), err) } @@ -2938,12 +3073,39 @@ func (s *Store) ServiceTopology( maxIdx = idx } - // The given service is a destination relative to its downstreams - _, dstIntentions, err := compatIntentionMatchOneTxn(tx, ws, matchEntry, structs.IntentionMatchDestination) + // Store downstreams with at least one instance in transparent proxy mode. + // This is to avoid returning downstreams from intentions when none of the downstreams are transparent proxies. + tproxyMap := make(map[structs.ServiceName]struct{}) + for _, downstream := range unfilteredDownstreams { + if downstream.Service.Proxy.Mode == structs.ProxyModeTransparent { + sn := structs.NewServiceName(downstream.Service.Proxy.DestinationServiceName, &downstream.Service.EnterpriseMeta) + tproxyMap[sn] = struct{}{} + } + } + + var downstreams structs.CheckServiceNodes + for _, downstream := range unfilteredDownstreams { + sn := downstream.Service.CompoundServiceName() + if downstream.Service.Kind == structs.ServiceKindConnectProxy { + sn = structs.NewServiceName(downstream.Service.Proxy.DestinationServiceName, &downstream.Service.EnterpriseMeta) + } + if _, ok := tproxyMap[sn]; !ok && downstreamSources[sn.String()] != structs.TopologySourceRegistration { + continue + } + downstreams = append(downstreams, downstream) + } + + _, dstIntentions, err := compatIntentionMatchOneTxn( + tx, + ws, + matchEntry, + + // The given service is a destination relative to its downstreams + structs.IntentionMatchDestination, + ) if err != nil { return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String()) } - downstreamDecisions := make(map[string]structs.IntentionDecisionSummary) for _, dn := range downstreamNames { decision, err := s.IntentionDecision(dn.Name, dn.NamespaceOrDefault(), dstIntentions, structs.IntentionMatchSource, defaultAllow, false) if err != nil { @@ -2954,11 +3116,14 @@ func (s *Store) ServiceTopology( } resp := &structs.ServiceTopology{ + TransparentProxy: fullyTransparent, MetricsProtocol: protocol, Upstreams: upstreams, Downstreams: downstreams, UpstreamDecisions: upstreamDecisions, DownstreamDecisions: downstreamDecisions, + UpstreamSources: upstreamSources, + DownstreamSources: downstreamSources, } return maxIdx, resp, nil } @@ -2995,7 +3160,6 @@ func (s *Store) combinedServiceNodesTxn(tx ReadTxn, ws memdb.WatchSet, names []s // downstreamsForServiceTxn will find all downstream services that could route traffic to the input service. // There are two factors at play. Upstreams defined in a proxy registration, and the discovery chain for those upstreams. -// TODO (freddy): Account for ingress gateways func (s *Store) downstreamsForServiceTxn(tx ReadTxn, ws memdb.WatchSet, dc string, service structs.ServiceName) (uint64, []structs.ServiceName, error) { // First fetch services that have discovery chains that eventually route to the target service idx, sources, err := s.discoveryChainSourcesTxn(tx, ws, dc, service) diff --git a/agent/consul/state/intention.go b/agent/consul/state/intention.go index 418e3ee8d3..2c0b1e0a56 100644 --- a/agent/consul/state/intention.go +++ b/agent/consul/state/intention.go @@ -750,10 +750,12 @@ func (s *Store) IntentionDecision( } } - var resp structs.IntentionDecisionSummary + resp := structs.IntentionDecisionSummary{ + DefaultAllow: defaultDecision == acl.Allow, + } if ixnMatch == nil { // No intention found, fall back to default - resp.Allowed = defaultDecision == acl.Allow + resp.Allowed = resp.DefaultAllow return resp, nil } @@ -931,6 +933,11 @@ func intentionMatchGetParams(entry structs.IntentionMatchEntry) ([][]interface{} return result, nil } +type ServiceWithDecision struct { + Name structs.ServiceName + Decision structs.IntentionDecisionSummary +} + // IntentionTopology returns the upstreams or downstreams of a service. Upstreams and downstreams are inferred from // intentions. If intentions allow a connection from the target to some candidate service, the candidate service is considered // an upstream of the target. @@ -939,6 +946,25 @@ func (s *Store) IntentionTopology(ws memdb.WatchSet, tx := s.db.ReadTxn() defer tx.Abort() + idx, services, err := s.intentionTopologyTxn(tx, ws, target, downstreams, defaultDecision) + if err != nil { + requested := "upstreams" + if downstreams { + requested = "downstreams" + } + return 0, nil, fmt.Errorf("failed to fetch %s for %s: %v", requested, target.String(), err) + } + + resp := make(structs.ServiceList, 0) + for _, svc := range services { + resp = append(resp, svc.Name) + } + return idx, resp, nil +} + +func (s *Store) intentionTopologyTxn(tx ReadTxn, ws memdb.WatchSet, + target structs.ServiceName, downstreams bool, defaultDecision acl.EnforcementDecision) (uint64, []ServiceWithDecision, error) { + var maxIdx uint64 // If querying the upstreams for a service, we first query intentions that apply to the target service as a source. @@ -997,7 +1023,7 @@ func (s *Store) IntentionTopology(ws memdb.WatchSet, if downstreams { decisionMatchType = structs.IntentionMatchSource } - result := make(structs.ServiceList, 0, len(allServices)) + result := make([]ServiceWithDecision, 0, len(allServices)) for _, candidate := range allServices { if candidate.Name == structs.ConsulServiceName { continue @@ -1014,7 +1040,11 @@ func (s *Store) IntentionTopology(ws memdb.WatchSet, if !decision.Allowed || target.Matches(candidate) { continue } - result = append(result, candidate) + + result = append(result, ServiceWithDecision{ + Name: candidate, + Decision: decision, + }) } return maxIdx, result, err } diff --git a/agent/consul/state/intention_test.go b/agent/consul/state/intention_test.go index 39d564b097..ffd5c96749 100644 --- a/agent/consul/state/intention_test.go +++ b/agent/consul/state/intention_test.go @@ -1774,7 +1774,10 @@ func TestStore_IntentionDecision(t *testing.T) { dst: "ditto", matchType: structs.IntentionMatchDestination, defaultDecision: acl.Deny, - expect: structs.IntentionDecisionSummary{Allowed: false}, + expect: structs.IntentionDecisionSummary{ + Allowed: false, + DefaultAllow: false, + }, }, { name: "no matching intention and default allow", @@ -1782,7 +1785,10 @@ func TestStore_IntentionDecision(t *testing.T) { dst: "ditto", matchType: structs.IntentionMatchDestination, defaultDecision: acl.Allow, - expect: structs.IntentionDecisionSummary{Allowed: true}, + expect: structs.IntentionDecisionSummary{ + Allowed: true, + DefaultAllow: true, + }, }, { name: "denied with permissions", diff --git a/agent/structs/connect_proxy_config.go b/agent/structs/connect_proxy_config.go index db4026aba4..eff98a0d74 100644 --- a/agent/structs/connect_proxy_config.go +++ b/agent/structs/connect_proxy_config.go @@ -35,6 +35,22 @@ const ( MeshGatewayModeRemote MeshGatewayMode = "remote" ) +const ( + // TODO (freddy) Should we have a TopologySourceMixed when there is a mix of proxy reg and tproxy? + // Currently we label as proxy-registration if ANY instance has the explicit upstream definition. + // TopologySourceRegistration is used to label upstreams or downstreams from explicit upstream definitions + TopologySourceRegistration = "proxy-registration" + + // TopologySourceSpecificIntention is used to label upstreams or downstreams from specific intentions + TopologySourceSpecificIntention = "specific-intention" + + // TopologySourceWildcardIntention is used to label upstreams or downstreams from wildcard intentions + TopologySourceWildcardIntention = "wildcard-intention" + + // TopologySourceDefaultAllow is used to label upstreams or downstreams from default allow ACL policy + TopologySourceDefaultAllow = "default-allow" +) + // MeshGatewayConfig controls how Mesh Gateways are configured and used // This is a struct to allow for future additions without having more free-hanging // configuration items all over the place diff --git a/agent/structs/intention.go b/agent/structs/intention.go index 2b33ddad19..f5ef8ebb38 100644 --- a/agent/structs/intention.go +++ b/agent/structs/intention.go @@ -666,12 +666,14 @@ type IntentionQueryCheckResponse struct { // - Whether all actions are allowed // - Whether the matching intention has L7 permissions attached // - Whether the intention is managed by an external source like k8s -// - Whether there is an exact, on-wildcard, intention referencing the two services +// - Whether there is an exact, or wildcard, intention referencing the two services +// - Whether ACLs are in DefaultAllow mode type IntentionDecisionSummary struct { Allowed bool HasPermissions bool ExternalSource string HasExact bool + DefaultAllow bool } // IntentionQueryExact holds the parameters for performing a lookup of an diff --git a/agent/structs/structs.go b/agent/structs/structs.go index 2146f89e8e..7be5e5c1aa 100644 --- a/agent/structs/structs.go +++ b/agent/structs/structs.go @@ -1924,6 +1924,17 @@ type ServiceTopology struct { // MetricsProtocol is the protocol of the service being queried MetricsProtocol string + + // TransparentProxy describes whether all instances of the proxy + // service are in transparent mode. + TransparentProxy bool + + // (Up|Down)streamSources are maps with labels for why each service is being + // returned. Services can be upstreams or downstreams due to + // explicit upstream definition or various types of intention policies: + // specific, wildcard, or default allow. + UpstreamSources map[string]string + DownstreamSources map[string]string } // IndexedConfigEntries has its own encoding logic which differs from diff --git a/agent/ui_endpoint.go b/agent/ui_endpoint.go index 6cfa349819..f623770a75 100644 --- a/agent/ui_endpoint.go +++ b/agent/ui_endpoint.go @@ -19,19 +19,21 @@ import ( // ServiceSummary is used to summarize a service type ServiceSummary struct { - Kind structs.ServiceKind `json:",omitempty"` - Name string - Datacenter string - Tags []string - Nodes []string - ExternalSources []string - externalSourceSet map[string]struct{} // internal to track uniqueness - checks map[string]*structs.HealthCheck - InstanceCount int - ChecksPassing int - ChecksWarning int - ChecksCritical int - GatewayConfig GatewayConfig + Kind structs.ServiceKind `json:",omitempty"` + Name string + Datacenter string + Tags []string + Nodes []string + ExternalSources []string + externalSourceSet map[string]struct{} // internal to track uniqueness + checks map[string]*structs.HealthCheck + InstanceCount int + ChecksPassing int + ChecksWarning int + ChecksCritical int + GatewayConfig GatewayConfig + TransparentProxy bool + transparentProxySet bool structs.EnterpriseMeta } @@ -61,14 +63,16 @@ type ServiceListingSummary struct { type ServiceTopologySummary struct { ServiceSummary + Source string Intention structs.IntentionDecisionSummary } type ServiceTopology struct { - Protocol string - Upstreams []*ServiceTopologySummary - Downstreams []*ServiceTopologySummary - FilteredByACLs bool + Protocol string + TransparentProxy bool + Upstreams []*ServiceTopologySummary + Downstreams []*ServiceTopologySummary + FilteredByACLs bool } // UINodes is used to list the nodes in a given datacenter. We return a @@ -334,6 +338,7 @@ RPC: sum := ServiceTopologySummary{ ServiceSummary: *svc, Intention: out.ServiceTopology.UpstreamDecisions[sn.String()], + Source: out.ServiceTopology.UpstreamSources[sn.String()], } upstreamResp = append(upstreamResp, &sum) } @@ -344,15 +349,17 @@ RPC: sum := ServiceTopologySummary{ ServiceSummary: *svc, Intention: out.ServiceTopology.DownstreamDecisions[sn.String()], + Source: out.ServiceTopology.DownstreamSources[sn.String()], } downstreamResp = append(downstreamResp, &sum) } topo := ServiceTopology{ - Protocol: out.ServiceTopology.MetricsProtocol, - Upstreams: upstreamResp, - Downstreams: downstreamResp, - FilteredByACLs: out.FilteredByACLs, + TransparentProxy: out.ServiceTopology.TransparentProxy, + Protocol: out.ServiceTopology.MetricsProtocol, + Upstreams: upstreamResp, + Downstreams: downstreamResp, + FilteredByACLs: out.FilteredByACLs, } return topo, nil } @@ -410,6 +417,17 @@ func summarizeServices(dump structs.ServiceDump, cfg *config.RuntimeConfig, dc s } destination.checks[uid] = check } + + // Only consider the target service to be transparent when all its proxy instances are in that mode. + // This is done because the flag is used to display warnings about proxies needing to enable + // transparent proxy mode. If ANY instance isn't in the right mode then the warming applies. + if svc.Proxy.Mode == structs.ProxyModeTransparent && !destination.transparentProxySet { + destination.TransparentProxy = true + } + if svc.Proxy.Mode != structs.ProxyModeTransparent { + destination.TransparentProxy = false + } + destination.transparentProxySet = true } for _, tag := range svc.Tags { found := false diff --git a/agent/ui_endpoint_test.go b/agent/ui_endpoint_test.go index 31638e8ba5..519770841c 100644 --- a/agent/ui_endpoint_test.go +++ b/agent/ui_endpoint_test.go @@ -396,6 +396,7 @@ func TestUiServices(t *testing.T) { // internal accounting that users don't see can be blown away for _, sum := range summary { + sum.transparentProxySet = false sum.externalSourceSet = nil sum.checks = nil } @@ -1078,12 +1079,7 @@ func TestUIServiceTopology(t *testing.T) { Address: "198.18.1.2", Proxy: structs.ConnectProxyConfig{ DestinationServiceName: "api", - Upstreams: structs.Upstreams{ - { - DestinationName: "web", - LocalBindPort: 8080, - }, - }, + Mode: structs.ProxyModeTransparent, }, }, Checks: structs.HealthChecks{ @@ -1210,12 +1206,7 @@ func TestUIServiceTopology(t *testing.T) { Address: "198.18.1.40", Proxy: structs.ConnectProxyConfig{ DestinationServiceName: "web", - Upstreams: structs.Upstreams{ - { - DestinationName: "redis", - LocalBindPort: 123, - }, - }, + Mode: structs.ProxyModeTransparent, }, }, Checks: structs.HealthChecks{ @@ -1296,8 +1287,10 @@ func TestUIServiceTopology(t *testing.T) { } } - // Add intentions: deny all, ingress -> api, web -> redis with L7 perms, but omit intention for api -> web - // Add ingress config: ingress -> api + // ingress -> api gateway config entry (but no intention) + // wildcard deny intention + // api -> web exact intention + // web -> redis exact intention { entries := []structs.ConfigEntryRequest{ { @@ -1318,6 +1311,20 @@ func TestUIServiceTopology(t *testing.T) { Protocol: "tcp", }, }, + { + Datacenter: "dc1", + Entry: &structs.ServiceIntentionsConfigEntry{ + Kind: structs.ServiceIntentions, + Name: "*", + Meta: map[string]string{structs.MetaExternalSource: "nomad"}, + Sources: []*structs.SourceIntention{ + { + Name: "*", + Action: structs.IntentionActionDeny, + }, + }, + }, + }, { Datacenter: "dc1", Entry: &structs.ServiceIntentionsConfigEntry{ @@ -1342,12 +1349,11 @@ func TestUIServiceTopology(t *testing.T) { Datacenter: "dc1", Entry: &structs.ServiceIntentionsConfigEntry{ Kind: structs.ServiceIntentions, - Name: "*", - Meta: map[string]string{structs.MetaExternalSource: "nomad"}, + Name: "web", Sources: []*structs.SourceIntention{ { - Name: "*", - Action: structs.IntentionActionDeny, + Action: structs.IntentionActionAllow, + Name: "api", }, }, }, @@ -1419,22 +1425,26 @@ func TestUIServiceTopology(t *testing.T) { require.NoError(r, checkIndex(resp)) expect := ServiceTopology{ - Protocol: "tcp", + Protocol: "tcp", + TransparentProxy: false, Upstreams: []*ServiceTopologySummary{ { ServiceSummary: ServiceSummary{ - Name: "api", - Datacenter: "dc1", - Nodes: []string{"foo"}, - InstanceCount: 1, - ChecksPassing: 3, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + Name: "api", + Datacenter: "dc1", + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 3, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + TransparentProxy: true, }, Intention: structs.IntentionDecisionSummary{ + DefaultAllow: true, Allowed: true, HasPermissions: false, HasExact: true, }, + Source: structs.TopologySourceRegistration, }, }, Downstreams: []*ServiceTopologySummary{}, @@ -1446,6 +1456,7 @@ func TestUIServiceTopology(t *testing.T) { for _, u := range result.Upstreams { u.externalSourceSet = nil u.checks = nil + u.transparentProxySet = false } require.Equal(r, expect, result) }) @@ -1461,45 +1472,49 @@ func TestUIServiceTopology(t *testing.T) { require.NoError(r, checkIndex(resp)) expect := ServiceTopology{ - Protocol: "tcp", + Protocol: "tcp", + TransparentProxy: true, Downstreams: []*ServiceTopologySummary{ { ServiceSummary: ServiceSummary{ - Name: "ingress", - Kind: structs.ServiceKindIngressGateway, - Datacenter: "dc1", - Nodes: []string{"edge"}, - InstanceCount: 1, - ChecksPassing: 1, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + Name: "ingress", + Kind: structs.ServiceKindIngressGateway, + Datacenter: "dc1", + Nodes: []string{"edge"}, + InstanceCount: 1, + ChecksPassing: 1, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + TransparentProxy: false, }, Intention: structs.IntentionDecisionSummary{ + DefaultAllow: true, Allowed: true, HasPermissions: false, HasExact: true, }, + Source: structs.TopologySourceRegistration, }, }, Upstreams: []*ServiceTopologySummary{ { ServiceSummary: ServiceSummary{ - Name: "web", - Datacenter: "dc1", - Nodes: []string{"bar", "baz"}, - InstanceCount: 2, - ChecksPassing: 3, - ChecksWarning: 1, - ChecksCritical: 2, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + Name: "web", + Datacenter: "dc1", + Nodes: []string{"bar", "baz"}, + InstanceCount: 2, + ChecksPassing: 3, + ChecksWarning: 1, + ChecksCritical: 2, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + TransparentProxy: false, }, Intention: structs.IntentionDecisionSummary{ - Allowed: false, + DefaultAllow: true, + Allowed: true, HasPermissions: false, - ExternalSource: "nomad", - - // From wildcard deny - HasExact: false, + HasExact: true, }, + Source: structs.TopologySourceSpecificIntention, }, }, FilteredByACLs: false, @@ -1508,10 +1523,12 @@ func TestUIServiceTopology(t *testing.T) { // Internal accounting that is not returned in JSON response for _, u := range result.Upstreams { + u.transparentProxySet = false u.externalSourceSet = nil u.checks = nil } for _, d := range result.Downstreams { + d.transparentProxySet = false d.externalSourceSet = nil d.checks = nil } @@ -1529,43 +1546,47 @@ func TestUIServiceTopology(t *testing.T) { require.NoError(r, checkIndex(resp)) expect := ServiceTopology{ - Protocol: "http", + Protocol: "http", + TransparentProxy: false, Upstreams: []*ServiceTopologySummary{ { ServiceSummary: ServiceSummary{ - Name: "redis", - Datacenter: "dc1", - Nodes: []string{"zip"}, - InstanceCount: 1, - ChecksPassing: 2, - ChecksCritical: 1, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + Name: "redis", + Datacenter: "dc1", + Nodes: []string{"zip"}, + InstanceCount: 1, + ChecksPassing: 2, + ChecksCritical: 1, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + TransparentProxy: false, }, Intention: structs.IntentionDecisionSummary{ + DefaultAllow: true, Allowed: false, HasPermissions: true, HasExact: true, }, + Source: structs.TopologySourceRegistration, }, }, Downstreams: []*ServiceTopologySummary{ { ServiceSummary: ServiceSummary{ - Name: "api", - Datacenter: "dc1", - Nodes: []string{"foo"}, - InstanceCount: 1, - ChecksPassing: 3, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + Name: "api", + Datacenter: "dc1", + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 3, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + TransparentProxy: true, }, Intention: structs.IntentionDecisionSummary{ - Allowed: false, + DefaultAllow: true, + Allowed: true, HasPermissions: false, - ExternalSource: "nomad", - - // From wildcard deny - HasExact: false, + HasExact: true, }, + Source: structs.TopologySourceSpecificIntention, }, }, FilteredByACLs: false, @@ -1574,10 +1595,12 @@ func TestUIServiceTopology(t *testing.T) { // Internal accounting that is not returned in JSON response for _, u := range result.Upstreams { + u.transparentProxySet = false u.externalSourceSet = nil u.checks = nil } for _, d := range result.Downstreams { + d.transparentProxySet = false d.externalSourceSet = nil d.checks = nil } @@ -1610,10 +1633,12 @@ func TestUIServiceTopology(t *testing.T) { EnterpriseMeta: *structs.DefaultEnterpriseMeta(), }, Intention: structs.IntentionDecisionSummary{ + DefaultAllow: true, Allowed: false, HasPermissions: true, HasExact: true, }, + Source: structs.TopologySourceRegistration, }, }, FilteredByACLs: false, @@ -1622,6 +1647,7 @@ func TestUIServiceTopology(t *testing.T) { // Internal accounting that is not returned in JSON response for _, d := range result.Downstreams { + d.transparentProxySet = false d.externalSourceSet = nil d.checks = nil }