From 50fee12d62e30ac05a1fc88bb8792b5d8388212c Mon Sep 17 00:00:00 2001 From: Freddy Date: Tue, 11 Aug 2020 17:20:41 -0600 Subject: [PATCH] Internal endpoint to query intentions associated with a gateway (#8400) --- agent/consul/internal_endpoint.go | 84 +++++++++ agent/consul/internal_endpoint_test.go | 228 +++++++++++++++++++++++++ agent/consul/state/intention.go | 81 ++++++--- agent/consul/state/intention_test.go | 135 +++++++++++++++ agent/http_register.go | 1 + agent/ui_endpoint.go | 39 +++++ agent/ui_endpoint_test.go | 98 +++++++++++ 7 files changed, 644 insertions(+), 22 deletions(-) diff --git a/agent/consul/internal_endpoint.go b/agent/consul/internal_endpoint.go index 720810f0d..0a46fd6ca 100644 --- a/agent/consul/internal_endpoint.go +++ b/agent/consul/internal_endpoint.go @@ -205,6 +205,90 @@ func (m *Internal) GatewayServiceDump(args *structs.ServiceSpecificRequest, repl return err } +// Match returns the set of intentions that match the given source/destination. +func (m *Internal) GatewayIntentions(args *structs.IntentionQueryRequest, reply *structs.IndexedIntentions) error { + // Forward if necessary + if done, err := m.srv.ForwardRPC("Internal.GatewayIntentions", args, args, reply); done { + return err + } + + if len(args.Match.Entries) > 1 { + return fmt.Errorf("Expected 1 gateway name, got %d", len(args.Match.Entries)) + } + + // Get the ACL token for the request for the checks below. + var entMeta structs.EnterpriseMeta + var authzContext acl.AuthorizerContext + + authz, err := m.srv.ResolveTokenAndDefaultMeta(args.Token, &entMeta, &authzContext) + if err != nil { + return err + } + + if args.Match.Entries[0].Namespace == "" { + args.Match.Entries[0].Namespace = entMeta.NamespaceOrDefault() + } + if err := m.srv.validateEnterpriseIntentionNamespace(args.Match.Entries[0].Namespace, true); err != nil { + return fmt.Errorf("Invalid match entry namespace %q: %v", args.Match.Entries[0].Namespace, err) + } + + // We need read access to the gateway we're trying to find intentions for, so check that first. + if authz != nil && authz.ServiceRead(args.Match.Entries[0].Name, &authzContext) != acl.Allow { + return acl.ErrPermissionDenied + } + + return m.srv.blockingQuery( + &args.QueryOptions, + &reply.QueryMeta, + func(ws memdb.WatchSet, state *state.Store) error { + var maxIdx uint64 + idx, gatewayServices, err := state.GatewayServices(ws, args.Match.Entries[0].Name, &entMeta) + if err != nil { + return err + } + if idx > maxIdx { + maxIdx = idx + } + + // Loop over the gateway <-> serviceName mappings and fetch all intentions for each + seen := make(map[string]bool) + result := make(structs.Intentions, 0) + + for _, gs := range gatewayServices { + entry := structs.IntentionMatchEntry{ + Namespace: gs.Service.NamespaceOrDefault(), + Name: gs.Service.Name, + } + idx, intentions, err := state.IntentionMatchOne(ws, entry, structs.IntentionMatchDestination) + if err != nil { + return err + } + if idx > maxIdx { + maxIdx = idx + } + + // Deduplicate wildcard intentions + for _, ixn := range intentions { + if !seen[ixn.ID] { + result = append(result, ixn) + seen[ixn.ID] = true + } + } + } + + reply.Index, reply.Intentions = maxIdx, result + if reply.Intentions == nil { + reply.Intentions = make(structs.Intentions, 0) + } + + if err := m.srv.filterACL(args.Token, reply); err != nil { + return err + } + return nil + }, + ) +} + // EventFire is a bit of an odd endpoint, but it allows for a cross-DC RPC // call to fire an event. The primary use case is to enable user events being // triggered in a remote DC. diff --git a/agent/consul/internal_endpoint_test.go b/agent/consul/internal_endpoint_test.go index 94f8f8199..621cdb55c 100644 --- a/agent/consul/internal_endpoint_test.go +++ b/agent/consul/internal_endpoint_test.go @@ -1329,3 +1329,231 @@ func TestInternal_GatewayServiceDump_Ingress_ACL(t *testing.T) { require.Equal(t, nodes[0].Service.Service, "db") require.Equal(t, nodes[0].Checks[0].Status, api.HealthWarning) } + +func TestInternal_GatewayIntentions(t *testing.T) { + t.Parallel() + dir1, s1 := testServer(t) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForTestAgent(t, s1.RPC, "dc1") + + // Register terminating gateway and config entry linking it to postgres + redis + { + arg := structs.RegisterRequest{ + Datacenter: "dc1", + Node: "foo", + Address: "127.0.0.1", + Service: &structs.NodeService{ + ID: "terminating-gateway", + Service: "terminating-gateway", + Kind: structs.ServiceKindTerminatingGateway, + Port: 443, + }, + Check: &structs.HealthCheck{ + Name: "terminating connect", + Status: api.HealthPassing, + ServiceID: "terminating-gateway", + }, + } + var regOutput struct{} + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Catalog.Register", &arg, ®Output)) + + args := &structs.TerminatingGatewayConfigEntry{ + Name: "terminating-gateway", + Kind: structs.TerminatingGateway, + Services: []structs.LinkedService{ + { + Name: "postgres", + }, + { + Name: "redis", + CAFile: "/etc/certs/ca.pem", + CertFile: "/etc/certs/cert.pem", + KeyFile: "/etc/certs/key.pem", + }, + }, + } + + req := structs.ConfigEntryRequest{ + Op: structs.ConfigEntryUpsert, + Datacenter: "dc1", + Entry: args, + } + var configOutput bool + require.NoError(t, msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &req, &configOutput)) + require.True(t, configOutput) + } + + // create some symmetric intentions to ensure we are only matching on destination + { + for _, v := range []string{"*", "mysql", "redis", "postgres"} { + req := structs.IntentionRequest{ + Datacenter: "dc1", + Op: structs.IntentionOpCreate, + Intention: structs.TestIntention(t), + } + req.Intention.SourceName = "api" + req.Intention.DestinationName = v + + var reply string + assert.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Apply", &req, &reply)) + + req = structs.IntentionRequest{ + Datacenter: "dc1", + Op: structs.IntentionOpCreate, + Intention: structs.TestIntention(t), + } + req.Intention.SourceName = v + req.Intention.DestinationName = "api" + assert.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Apply", &req, &reply)) + } + } + + // Request intentions matching the gateway named "terminating-gateway" + req := structs.IntentionQueryRequest{ + Datacenter: "dc1", + Match: &structs.IntentionQueryMatch{ + Type: structs.IntentionMatchDestination, + Entries: []structs.IntentionMatchEntry{ + { + Namespace: structs.IntentionDefaultNamespace, + Name: "terminating-gateway", + }, + }, + }, + } + var reply structs.IndexedIntentions + assert.NoError(t, msgpackrpc.CallWithCodec(codec, "Internal.GatewayIntentions", &req, &reply)) + assert.Len(t, reply.Intentions, 3) + + // Only intentions with linked services as a destination should be returned, and wildcard matches should be deduped + expected := []string{"postgres", "*", "redis"} + actual := []string{ + reply.Intentions[0].DestinationName, + reply.Intentions[1].DestinationName, + reply.Intentions[2].DestinationName, + } + assert.ElementsMatch(t, expected, actual) +} + +func TestInternal_GatewayIntentions_aclDeny(t *testing.T) { + dir1, s1 := testServerWithConfig(t, testServerACLConfig(nil)) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + codec := rpcClient(t, s1) + defer codec.Close() + + testrpc.WaitForTestAgent(t, s1.RPC, "dc1", testrpc.WithToken(TestDefaultMasterToken)) + + // Register terminating gateway and config entry linking it to postgres + redis + { + arg := structs.RegisterRequest{ + Datacenter: "dc1", + Node: "foo", + Address: "127.0.0.1", + Service: &structs.NodeService{ + ID: "terminating-gateway", + Service: "terminating-gateway", + Kind: structs.ServiceKindTerminatingGateway, + Port: 443, + }, + Check: &structs.HealthCheck{ + Name: "terminating connect", + Status: api.HealthPassing, + ServiceID: "terminating-gateway", + }, + WriteRequest: structs.WriteRequest{Token: TestDefaultMasterToken}, + } + var regOutput struct{} + require.NoError(t, msgpackrpc.CallWithCodec(codec, "Catalog.Register", &arg, ®Output)) + + args := &structs.TerminatingGatewayConfigEntry{ + Name: "terminating-gateway", + Kind: structs.TerminatingGateway, + Services: []structs.LinkedService{ + { + Name: "postgres", + }, + { + Name: "redis", + CAFile: "/etc/certs/ca.pem", + CertFile: "/etc/certs/cert.pem", + KeyFile: "/etc/certs/key.pem", + }, + }, + } + + req := structs.ConfigEntryRequest{ + Op: structs.ConfigEntryUpsert, + Datacenter: "dc1", + Entry: args, + WriteRequest: structs.WriteRequest{Token: TestDefaultMasterToken}, + } + var configOutput bool + require.NoError(t, msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &req, &configOutput)) + require.True(t, configOutput) + } + + // create some symmetric intentions to ensure we are only matching on destination + { + for _, v := range []string{"*", "mysql", "redis", "postgres"} { + req := structs.IntentionRequest{ + Datacenter: "dc1", + Op: structs.IntentionOpCreate, + Intention: structs.TestIntention(t), + WriteRequest: structs.WriteRequest{Token: TestDefaultMasterToken}, + } + req.Intention.SourceName = "api" + req.Intention.DestinationName = v + + var reply string + assert.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Apply", &req, &reply)) + + req = structs.IntentionRequest{ + Datacenter: "dc1", + Op: structs.IntentionOpCreate, + Intention: structs.TestIntention(t), + WriteRequest: structs.WriteRequest{Token: TestDefaultMasterToken}, + } + req.Intention.SourceName = v + req.Intention.DestinationName = "api" + assert.NoError(t, msgpackrpc.CallWithCodec(codec, "Intention.Apply", &req, &reply)) + } + } + + userToken, err := upsertTestTokenWithPolicyRules(codec, TestDefaultMasterToken, "dc1", ` +service_prefix "redis" { policy = "read" } +service_prefix "terminating-gateway" { policy = "read" } +`) + require.NoError(t, err) + + // Request intentions matching the gateway named "terminating-gateway" + req := structs.IntentionQueryRequest{ + Datacenter: "dc1", + Match: &structs.IntentionQueryMatch{ + Type: structs.IntentionMatchDestination, + Entries: []structs.IntentionMatchEntry{ + { + Namespace: structs.IntentionDefaultNamespace, + Name: "terminating-gateway", + }, + }, + }, + QueryOptions: structs.QueryOptions{Token: userToken.SecretID}, + } + var reply structs.IndexedIntentions + assert.NoError(t, msgpackrpc.CallWithCodec(codec, "Internal.GatewayIntentions", &req, &reply)) + assert.Len(t, reply.Intentions, 2) + + // Only intentions for redis should be returned, due to ACLs + expected := []string{"*", "redis"} + actual := []string{ + reply.Intentions[0].DestinationName, + reply.Intentions[1].DestinationName, + } + assert.ElementsMatch(t, expected, actual) +} diff --git a/agent/consul/state/intention.go b/agent/consul/state/intention.go index 402051ac9..18b43f117 100644 --- a/agent/consul/state/intention.go +++ b/agent/consul/state/intention.go @@ -338,31 +338,11 @@ func (s *Store) IntentionMatch(ws memdb.WatchSet, args *structs.IntentionQueryMa // Make all the calls and accumulate the results results := make([]structs.Intentions, len(args.Entries)) for i, entry := range args.Entries { - // Each search entry may require multiple queries to memdb, so this - // returns the arguments for each necessary Get. Note on performance: - // this is not the most optimal set of queries since we repeat some - // many times (such as */*). We can work on improving that in the - // future, the test cases shouldn't have to change for that. - getParams, err := s.intentionMatchGetParams(entry) + ixns, err := s.intentionMatchOneTxn(tx, ws, entry, args.Type) if err != nil { return 0, nil, err } - // Perform each call and accumulate the result. - var ixns structs.Intentions - for _, params := range getParams { - iter, err := tx.Get(intentionsTableName, string(args.Type), params...) - if err != nil { - return 0, nil, fmt.Errorf("failed intention lookup: %s", err) - } - - ws.Add(iter.WatchCh()) - - for ixn := iter.Next(); ixn != nil; ixn = iter.Next() { - ixns = append(ixns, ixn.(*structs.Intention)) - } - } - // Sort the results by precedence sort.Sort(structs.IntentionPrecedenceSorter(ixns)) @@ -373,9 +353,66 @@ func (s *Store) IntentionMatch(ws memdb.WatchSet, args *structs.IntentionQueryMa return idx, results, nil } +// IntentionMatchOne returns the list of intentions that match the namespace and +// name for a single source or destination. This applies the resolution rules +// so wildcards will match any value. +// +// The returned intentions are sorted based on the intention precedence rules. +// i.e. result[0] is the highest precedent rule to match +func (s *Store) IntentionMatchOne(ws memdb.WatchSet, + entry structs.IntentionMatchEntry, matchType structs.IntentionMatchType) (uint64, structs.Intentions, error) { + tx := s.db.Txn(false) + defer tx.Abort() + + // Get the table index. + idx := maxIndexTxn(tx, intentionsTableName) + if idx < 1 { + idx = 1 + } + + results, err := s.intentionMatchOneTxn(tx, ws, entry, matchType) + if err != nil { + return 0, nil, err + } + + sort.Sort(structs.IntentionPrecedenceSorter(results)) + + return idx, results, nil +} + +func (s *Store) intentionMatchOneTxn(tx ReadTxn, ws memdb.WatchSet, + entry structs.IntentionMatchEntry, matchType structs.IntentionMatchType) (structs.Intentions, error) { + + // Each search entry may require multiple queries to memdb, so this + // returns the arguments for each necessary Get. Note on performance: + // this is not the most optimal set of queries since we repeat some + // many times (such as */*). We can work on improving that in the + // future, the test cases shouldn't have to change for that. + getParams, err := intentionMatchGetParams(entry) + if err != nil { + return nil, err + } + + // Perform each call and accumulate the result. + var result structs.Intentions + for _, params := range getParams { + iter, err := tx.Get(intentionsTableName, string(matchType), params...) + if err != nil { + return nil, fmt.Errorf("failed intention lookup: %s", err) + } + + ws.Add(iter.WatchCh()) + + for ixn := iter.Next(); ixn != nil; ixn = iter.Next() { + result = append(result, ixn.(*structs.Intention)) + } + } + return result, nil +} + // intentionMatchGetParams returns the tx.Get parameters to find all the // intentions for a certain entry. -func (s *Store) intentionMatchGetParams(entry structs.IntentionMatchEntry) ([][]interface{}, error) { +func intentionMatchGetParams(entry structs.IntentionMatchEntry) ([][]interface{}, error) { // We always query for "*/*" so include that. If the namespace is a // wildcard, then we're actually done. result := make([][]interface{}, 0, 3) diff --git a/agent/consul/state/intention_test.go b/agent/consul/state/intention_test.go index ec9999d33..be8a36be8 100644 --- a/agent/consul/state/intention_test.go +++ b/agent/consul/state/intention_test.go @@ -463,6 +463,141 @@ func TestStore_IntentionMatch_table(t *testing.T) { } } +// Equivalent to TestStore_IntentionMatch_table but for IntentionMatchOne which matches a single service +func TestStore_IntentionMatchOne_table(t *testing.T) { + type testCase struct { + Name string + Insert [][]string // List of intentions to insert + Query []string // List of intentions to match + Expected [][]string // List of matches, where each match is a list of intentions + } + + cases := []testCase{ + { + "single exact namespace/name", + [][]string{ + {"foo", "*"}, + {"foo", "bar"}, + {"foo", "baz"}, // shouldn't match + {"bar", "bar"}, // shouldn't match + {"bar", "*"}, // shouldn't match + {"*", "*"}, + }, + []string{ + "foo", "bar", + }, + [][]string{ + {"foo", "bar"}, + {"foo", "*"}, + {"*", "*"}, + }, + }, + { + "single exact namespace/name with duplicate destinations", + [][]string{ + // 4-tuple specifies src and destination to test duplicate destinations + // with different sources. We flip them around to test in both + // directions. The first pair are the ones searched on in both cases so + // the duplicates need to be there. + {"foo", "bar", "foo", "*"}, + {"foo", "bar", "bar", "*"}, + {"*", "*", "*", "*"}, + }, + []string{ + "foo", "bar", + }, + [][]string{ + // Note the first two have the same precedence so we rely on arbitrary + // lexicographical tie-break behavior. + {"foo", "bar", "bar", "*"}, + {"foo", "bar", "foo", "*"}, + {"*", "*", "*", "*"}, + }, + }, + } + + testRunner := func(t *testing.T, tc testCase, typ structs.IntentionMatchType) { + // Insert the set + assert := assert.New(t) + s := testStateStore(t) + var idx uint64 = 1 + for _, v := range tc.Insert { + ixn := &structs.Intention{ID: testUUID()} + switch typ { + case structs.IntentionMatchDestination: + ixn.DestinationNS = v[0] + ixn.DestinationName = v[1] + if len(v) == 4 { + ixn.SourceNS = v[2] + ixn.SourceName = v[3] + } + case structs.IntentionMatchSource: + ixn.SourceNS = v[0] + ixn.SourceName = v[1] + if len(v) == 4 { + ixn.DestinationNS = v[2] + ixn.DestinationName = v[3] + } + } + + assert.NoError(s.IntentionSet(idx, ixn)) + + idx++ + } + + // Build the arguments and match + entry := structs.IntentionMatchEntry{ + Namespace: tc.Query[0], + Name: tc.Query[1], + } + _, matches, err := s.IntentionMatchOne(nil, entry, typ) + assert.NoError(err) + + // Should have equal lengths + require.Len(t, matches, len(tc.Expected)) + + // Verify matches + var actual [][]string + for _, ixn := range matches { + switch typ { + case structs.IntentionMatchDestination: + if len(tc.Expected) > 1 && len(tc.Expected[0]) == 4 { + actual = append(actual, []string{ + ixn.DestinationNS, + ixn.DestinationName, + ixn.SourceNS, + ixn.SourceName, + }) + } else { + actual = append(actual, []string{ixn.DestinationNS, ixn.DestinationName}) + } + case structs.IntentionMatchSource: + if len(tc.Expected) > 1 && len(tc.Expected[0]) == 4 { + actual = append(actual, []string{ + ixn.SourceNS, + ixn.SourceName, + ixn.DestinationNS, + ixn.DestinationName, + }) + } else { + actual = append(actual, []string{ixn.SourceNS, ixn.SourceName}) + } + } + } + assert.Equal(tc.Expected, actual) + } + + for _, tc := range cases { + t.Run(tc.Name+" (destination)", func(t *testing.T) { + testRunner(t, tc, structs.IntentionMatchDestination) + }) + + t.Run(tc.Name+" (source)", func(t *testing.T) { + testRunner(t, tc, structs.IntentionMatchSource) + }) + } +} + func TestStore_Intention_Snapshot_Restore(t *testing.T) { assert := assert.New(t) s := testStateStore(t) diff --git a/agent/http_register.go b/agent/http_register.go index 3d9d55c73..588e5a977 100644 --- a/agent/http_register.go +++ b/agent/http_register.go @@ -98,6 +98,7 @@ func init() { registerEndpoint("/v1/internal/ui/node/", []string{"GET"}, (*HTTPServer).UINodeInfo) registerEndpoint("/v1/internal/ui/services", []string{"GET"}, (*HTTPServer).UIServices) registerEndpoint("/v1/internal/ui/gateway-services-nodes/", []string{"GET"}, (*HTTPServer).UIGatewayServicesNodes) + registerEndpoint("/v1/internal/ui/gateway-intentions/", []string{"GET"}, (*HTTPServer).UIGatewayIntentions) registerEndpoint("/v1/internal/acl/authorize", []string{"POST"}, (*HTTPServer).ACLAuthorize) registerEndpoint("/v1/kv/", []string{"GET", "PUT", "DELETE"}, (*HTTPServer).KVSEndpoint) registerEndpoint("/v1/operator/raft/configuration", []string{"GET"}, (*HTTPServer).OperatorRaftConfiguration) diff --git a/agent/ui_endpoint.go b/agent/ui_endpoint.go index 202297d98..596afcfda 100644 --- a/agent/ui_endpoint.go +++ b/agent/ui_endpoint.go @@ -339,3 +339,42 @@ func modifySummaryForGatewayService( } } } + +// GET /v1/internal/ui/gateway-intentions/:gateway +func (s *HTTPServer) UIGatewayIntentions(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + var args structs.IntentionQueryRequest + if done := s.parse(resp, req, &args.Datacenter, &args.QueryOptions); done { + return nil, nil + } + + var entMeta structs.EnterpriseMeta + if err := s.parseEntMetaNoWildcard(req, &entMeta); err != nil { + return nil, err + } + + // Pull out the service name + name := strings.TrimPrefix(req.URL.Path, "/v1/internal/ui/gateway-intentions/") + if name == "" { + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprint(resp, "Missing gateway name") + return nil, nil + } + args.Match = &structs.IntentionQueryMatch{ + Type: structs.IntentionMatchDestination, + Entries: []structs.IntentionMatchEntry{ + { + Namespace: entMeta.NamespaceOrEmpty(), + Name: name, + }, + }, + } + + var reply structs.IndexedIntentions + + defer setMeta(resp, &reply.QueryMeta) + if err := s.agent.RPC("Internal.GatewayIntentions", args, &reply); err != nil { + return nil, err + } + + return reply.Intentions, nil +} diff --git a/agent/ui_endpoint_test.go b/agent/ui_endpoint_test.go index 4694e392b..eab3c4193 100644 --- a/agent/ui_endpoint_test.go +++ b/agent/ui_endpoint_test.go @@ -700,3 +700,101 @@ func TestUIGatewayServiceNodes_Ingress(t *testing.T) { } assert.ElementsMatch(t, expect, dump) } + +func TestUIGatewayIntentions(t *testing.T) { + t.Parallel() + + a := NewTestAgent(t, "") + defer a.Shutdown() + + // Register terminating gateway and config entry linking it to postgres + redis + { + arg := structs.RegisterRequest{ + Datacenter: "dc1", + Node: "foo", + Address: "127.0.0.1", + Service: &structs.NodeService{ + ID: "terminating-gateway", + Service: "terminating-gateway", + Kind: structs.ServiceKindTerminatingGateway, + Port: 443, + }, + Check: &structs.HealthCheck{ + Name: "terminating connect", + Status: api.HealthPassing, + ServiceID: "terminating-gateway", + }, + } + var regOutput struct{} + require.NoError(t, a.RPC("Catalog.Register", &arg, ®Output)) + + args := &structs.TerminatingGatewayConfigEntry{ + Name: "terminating-gateway", + Kind: structs.TerminatingGateway, + Services: []structs.LinkedService{ + { + Name: "postgres", + }, + { + Name: "redis", + CAFile: "/etc/certs/ca.pem", + CertFile: "/etc/certs/cert.pem", + KeyFile: "/etc/certs/key.pem", + }, + }, + } + + req := structs.ConfigEntryRequest{ + Op: structs.ConfigEntryUpsert, + Datacenter: "dc1", + Entry: args, + } + var configOutput bool + require.NoError(t, a.RPC("ConfigEntry.Apply", &req, &configOutput)) + require.True(t, configOutput) + } + + // create some symmetric intentions to ensure we are only matching on destination + { + for _, v := range []string{"*", "mysql", "redis", "postgres"} { + req := structs.IntentionRequest{ + Datacenter: "dc1", + Op: structs.IntentionOpCreate, + Intention: structs.TestIntention(t), + } + req.Intention.SourceName = "api" + req.Intention.DestinationName = v + + var reply string + assert.NoError(t, a.RPC("Intention.Apply", &req, &reply)) + + req = structs.IntentionRequest{ + Datacenter: "dc1", + Op: structs.IntentionOpCreate, + Intention: structs.TestIntention(t), + } + req.Intention.SourceName = v + req.Intention.DestinationName = "api" + assert.NoError(t, a.RPC("Intention.Apply", &req, &reply)) + } + } + + // Request intentions matching the gateway named "terminating-gateway" + req, _ := http.NewRequest("GET", "/v1/internal/ui/gateway-intentions/terminating-gateway", nil) + resp := httptest.NewRecorder() + obj, err := a.srv.UIGatewayIntentions(resp, req) + assert.Nil(t, err) + assertIndex(t, resp) + + intentions := obj.(structs.Intentions) + assert.Len(t, intentions, 3) + + // Only intentions with linked services as a destination should be returned, and wildcard matches should be deduped + expected := []string{"postgres", "*", "redis"} + actual := []string{ + intentions[0].DestinationName, + intentions[1].DestinationName, + intentions[2].DestinationName, + } + assert.ElementsMatch(t, expected, actual) +}