// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package agent
import (
"context"
"fmt"
"net"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/internal/resource"
pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1"
"github.com/hashicorp/consul/proto-public/pbresource"
"github.com/hashicorp/consul/sdk/testutil/retry"
"github.com/hashicorp/consul/testrpc"
)
// Similar to TestDNS_ServiceLookup, but removes config for features unsupported in v2 and
// tests against DNS v2 and Catalog v2 explicitly using a resource API client.
func TestDNS_CatalogV2_Basic ( t * testing . T ) {
if testing . Short ( ) {
t . Skip ( "too slow for testing.Short" )
}
var err error
a := NewTestAgent ( t , ` experiments=["resource-apis"] ` ) // v2dns is implicit w/ resource-apis
defer a . Shutdown ( )
testrpc . WaitForRaftLeader ( t , a . RPC , "dc1" )
client := a . delegate . ResourceServiceClient ( )
// Smoke test for `consul-server` service.
readResource ( t , client , & pbresource . ID {
Name : structs . ConsulServiceName ,
Type : pbcatalog . ServiceType ,
Tenancy : resource . DefaultNamespacedTenancy ( ) ,
} , new ( pbcatalog . Service ) )
// Register a new service.
dbServiceId := & pbresource . ID {
Name : "db" ,
Type : pbcatalog . ServiceType ,
Tenancy : resource . DefaultNamespacedTenancy ( ) ,
}
emptyServiceId := & pbresource . ID {
Name : "empty" ,
Type : pbcatalog . ServiceType ,
Tenancy : resource . DefaultNamespacedTenancy ( ) ,
}
dbService := & pbcatalog . Service {
Workloads : & pbcatalog . WorkloadSelector {
Prefixes : [ ] string { "db-" } ,
} ,
Ports : [ ] * pbcatalog . ServicePort {
{
TargetPort : "tcp" ,
Protocol : pbcatalog . Protocol_PROTOCOL_TCP ,
} ,
{
TargetPort : "admin" ,
Protocol : pbcatalog . Protocol_PROTOCOL_HTTP ,
} ,
{
TargetPort : "mesh" ,
Protocol : pbcatalog . Protocol_PROTOCOL_MESH ,
} ,
} ,
}
emptyService := & pbcatalog . Service {
Workloads : & pbcatalog . WorkloadSelector {
Prefixes : [ ] string { "empty-" } ,
} ,
Ports : [ ] * pbcatalog . ServicePort {
{
TargetPort : "tcp" ,
Protocol : pbcatalog . Protocol_PROTOCOL_TCP ,
} ,
{
TargetPort : "admin" ,
Protocol : pbcatalog . Protocol_PROTOCOL_HTTP ,
} ,
{
TargetPort : "mesh" ,
Protocol : pbcatalog . Protocol_PROTOCOL_MESH ,
} ,
} ,
}
dbServiceResource := & pbresource . Resource {
Id : dbServiceId ,
Data : toAny ( t , dbService ) ,
}
emptyServiceResource := & pbresource . Resource {
Id : emptyServiceId ,
Data : toAny ( t , emptyService ) ,
}
for _ , r := range [ ] * pbresource . Resource { dbServiceResource , emptyServiceResource } {
_ , err := client . Write ( context . Background ( ) , & pbresource . WriteRequest { Resource : r } )
if err != nil {
t . Fatalf ( "failed to create the %s service: %v" , r . Id . Name , err )
}
}
// Validate services written.
readResource ( t , client , dbServiceId , new ( pbcatalog . Service ) )
readResource ( t , client , emptyServiceId , new ( pbcatalog . Service ) )
// Register workloads.
dbWorkloadId1 := & pbresource . ID {
Name : "db-1" ,
Type : pbcatalog . WorkloadType ,
Tenancy : resource . DefaultNamespacedTenancy ( ) ,
}
dbWorkloadId2 := & pbresource . ID {
Name : "db-2" ,
Type : pbcatalog . WorkloadType ,
Tenancy : resource . DefaultNamespacedTenancy ( ) ,
}
dbWorkloadId3 := & pbresource . ID {
Name : "db-3" ,
Type : pbcatalog . WorkloadType ,
Tenancy : resource . DefaultNamespacedTenancy ( ) ,
}
dbWorkloadPorts := map [ string ] * pbcatalog . WorkloadPort {
"tcp" : {
Port : 12345 ,
Protocol : pbcatalog . Protocol_PROTOCOL_TCP ,
} ,
"admin" : {
Port : 23456 ,
Protocol : pbcatalog . Protocol_PROTOCOL_HTTP ,
} ,
"mesh" : {
Port : 20000 ,
Protocol : pbcatalog . Protocol_PROTOCOL_MESH ,
} ,
}
dbWorkloadFn := func ( ip string ) * pbcatalog . Workload {
return & pbcatalog . Workload {
Addresses : [ ] * pbcatalog . WorkloadAddress {
{
Host : ip ,
} ,
} ,
Identity : "test-identity" ,
Ports : dbWorkloadPorts ,
}
}
dbWorkload1 := dbWorkloadFn ( "172.16.1.1" )
_ , err = client . Write ( context . Background ( ) , & pbresource . WriteRequest { Resource : & pbresource . Resource {
Id : dbWorkloadId1 ,
Data : toAny ( t , dbWorkload1 ) ,
} } )
if err != nil {
t . Fatalf ( "failed to create the %s workload: %v" , dbWorkloadId1 . Name , err )
}
dbWorkload2 := dbWorkloadFn ( "172.16.1.2" )
_ , err = client . Write ( context . Background ( ) , & pbresource . WriteRequest { Resource : & pbresource . Resource {
Id : dbWorkloadId2 ,
Data : toAny ( t , dbWorkload2 ) ,
} } )
if err != nil {
t . Fatalf ( "failed to create the %s workload: %v" , dbWorkloadId2 . Name , err )
}
dbWorkload3 := dbWorkloadFn ( "2001:db8:85a3::8a2e:370:7334" ) // test IPv6
_ , err = client . Write ( context . Background ( ) , & pbresource . WriteRequest { Resource : & pbresource . Resource {
Id : dbWorkloadId3 ,
Data : toAny ( t , dbWorkload3 ) ,
} } )
if err != nil {
t . Fatalf ( "failed to create the %s workload: %v" , dbWorkloadId2 . Name , err )
}
// Validate workloads written.
dbWorkloads := make ( map [ string ] * pbcatalog . Workload )
dbWorkloads [ "db-1" ] = readResource ( t , client , dbWorkloadId1 , new ( pbcatalog . Workload ) ) . ( * pbcatalog . Workload )
dbWorkloads [ "db-2" ] = readResource ( t , client , dbWorkloadId2 , new ( pbcatalog . Workload ) ) . ( * pbcatalog . Workload )
dbWorkloads [ "db-3" ] = readResource ( t , client , dbWorkloadId3 , new ( pbcatalog . Workload ) ) . ( * pbcatalog . Workload )
// Ensure endpoints exist and have health status, which is required for inclusion in DNS results.
retry . Run ( t , func ( r * retry . R ) {
endpoints := readResource ( r , client , resource . ReplaceType ( pbcatalog . ServiceEndpointsType , dbServiceId ) , new ( pbcatalog . ServiceEndpoints ) ) . ( * pbcatalog . ServiceEndpoints )
require . Equal ( r , 3 , len ( endpoints . GetEndpoints ( ) ) )
for _ , e := range endpoints . GetEndpoints ( ) {
require . True ( r ,
// We only return results for passing and warning health checks.
e . HealthStatus == pbcatalog . Health_HEALTH_PASSING || e . HealthStatus == pbcatalog . Health_HEALTH_WARNING ,
fmt . Sprintf ( "unexpected health status: %v" , e . HealthStatus ) )
}
} )
// Test UDP and TCP clients.
for _ , client := range [ ] * dns . Client {
newDNSClient ( false ) ,
newDNSClient ( true ) ,
} {
// Lookup a service without matching workloads, we should receive an SOA and no answers.
questions := [ ] string {
"empty.service.consul." ,
"_empty._tcp.service.consul." ,
}
for _ , question := range questions {
for _ , dnsType := range [ ] uint16 { dns . TypeSRV , dns . TypeA , dns . TypeAAAA } {
m := new ( dns . Msg )
m . SetQuestion ( question , dnsType )
in , _ , err := client . Exchange ( m , a . DNSAddr ( ) )
if err != nil {
t . Fatalf ( "err: %v" , err )
}
require . Equal ( t , 0 , len ( in . Answer ) , "Bad: %s" , in . String ( ) )
require . Equal ( t , 0 , len ( in . Extra ) , "Bad: %s" , in . String ( ) )
require . Equal ( t , 1 , len ( in . Ns ) , "Bad: %s" , in . String ( ) )
soaRec , ok := in . Ns [ 0 ] . ( * dns . SOA )
require . True ( t , ok , "Bad: %s" , in . Ns [ 0 ] . String ( ) )
require . EqualValues ( t , 0 , soaRec . Hdr . Ttl , "Bad: %s" , in . Ns [ 0 ] . String ( ) )
}
}
// Look up the service directly including all ports.
questions = [ ] string {
"db.service.consul." ,
"_db._tcp.service.consul." , // RFC 2782 query. All ports are TCP, so this should return the same result.
}
for _ , question := range questions {
m := new ( dns . Msg )
m . SetQuestion ( question , dns . TypeSRV )
in , _ , err := client . Exchange ( m , a . DNSAddr ( ) )
if err != nil {
t . Fatalf ( "err: %v" , err )
}
// This check only runs for a TCP client because a UDP client will truncate the response.
if client . Net == "tcp" {
for portName , port := range dbWorkloadPorts {
for workloadName , workload := range dbWorkloads {
workloadTarget := fmt . Sprintf ( "%s.port.%s.workload.default.ns.default.ap.consul." , portName , workloadName )
workloadHost := workload . Addresses [ 0 ] . Host
srvRec := findSrvAnswerForTarget ( t , in , workloadTarget )
require . EqualValues ( t , port . Port , srvRec . Port , "Bad: %s" , srvRec . String ( ) )
require . EqualValues ( t , 0 , srvRec . Hdr . Ttl , "Bad: %s" , srvRec . String ( ) )
a := findAorAAAAForName ( t , in , in . Extra , workloadTarget )
require . Equal ( t , workloadHost , a . AorAAAA . String ( ) , "Bad: %s" , a . Original . String ( ) )
require . EqualValues ( t , 0 , a . Hdr . Ttl , "Bad: %s" , a . Original . String ( ) )
}
}
// Expect 1 result per port, per workload.
require . Equal ( t , 9 , len ( in . Answer ) , "answer count did not match expected\n\n%s" , in . String ( ) )
require . Equal ( t , 9 , len ( in . Extra ) , "extra answer count did not match expected\n\n%s" , in . String ( ) )
} else {
// Expect 1 result per port, per workload, up to the default limit of 3. In practice the results are truncated
// at 2 because of the record byte size.
require . Equal ( t , 2 , len ( in . Answer ) , "answer count did not match expected\n\n%s" , in . String ( ) )
require . Equal ( t , 2 , len ( in . Extra ) , "extra answer count did not match expected\n\n%s" , in . String ( ) )
}
}
// Look up the service directly by each port.
for portName , port := range dbWorkloadPorts {
question := fmt . Sprintf ( "%s.port.db.service.consul." , portName )
for workloadName , workload := range dbWorkloads {
workloadTarget := fmt . Sprintf ( "%s.port.%s.workload.default.ns.default.ap.consul." , portName , workloadName )
workloadHost := workload . Addresses [ 0 ] . Host
m := new ( dns . Msg )
m . SetQuestion ( question , dns . TypeSRV )
in , _ , err := client . Exchange ( m , a . DNSAddr ( ) )
if err != nil {
t . Fatalf ( "err: %v" , err )
}
srvRec := findSrvAnswerForTarget ( t , in , workloadTarget )
require . EqualValues ( t , port . Port , srvRec . Port , "Bad: %s" , srvRec . String ( ) )
require . EqualValues ( t , 0 , srvRec . Hdr . Ttl , "Bad: %s" , srvRec . String ( ) )
a := findAorAAAAForName ( t , in , in . Extra , workloadTarget )
require . Equal ( t , workloadHost , a . AorAAAA . String ( ) , "Bad: %s" , a . Original . String ( ) )
require . EqualValues ( t , 0 , a . Hdr . Ttl , "Bad: %s" , a . Original . String ( ) )
// Expect 1 result per port.
require . Equal ( t , 3 , len ( in . Answer ) , "answer count did not match expected\n\n%s" , in . String ( ) )
require . Equal ( t , 3 , len ( in . Extra ) , "extra answer count did not match expected\n\n%s" , in . String ( ) )
}
}
// Look up A/AAAA by service.
questions = [ ] string {
"db.service.consul." ,
}
for _ , question := range questions {
for workloadName , dnsType := range map [ string ] uint16 {
"db-1" : dns . TypeA ,
"db-2" : dns . TypeA ,
"db-3" : dns . TypeAAAA ,
} {
workload := dbWorkloads [ workloadName ]
m := new ( dns . Msg )
m . SetQuestion ( question , dnsType )
in , _ , err := client . Exchange ( m , a . DNSAddr ( ) )
if err != nil {
t . Fatalf ( "err: %v" , err )
}
workloadHost := workload . Addresses [ 0 ] . Host
a := findAorAAAAForAddress ( t , in , in . Answer , workloadHost )
require . Equal ( t , question , a . Hdr . Name , "Bad: %s" , a . Original . String ( ) )
require . EqualValues ( t , 0 , a . Hdr . Ttl , "Bad: %s" , a . Original . String ( ) )
// Expect 1 answer per workload. For A records, expect 2 answers because there's 2 IPv4 workloads.
if dnsType == dns . TypeA {
require . Equal ( t , 2 , len ( in . Answer ) , "answer count did not match expected\n\n%s" , in . String ( ) )
} else {
require . Equal ( t , 1 , len ( in . Answer ) , "answer count did not match expected\n\n%s" , in . String ( ) )
}
require . Equal ( t , 0 , len ( in . Extra ) , "extra answer count did not match expected\n\n%s" , in . String ( ) )
}
}
// Lookup a non-existing service, we should receive an SOA.
questions = [ ] string {
"nodb.service.consul." ,
"nope.query.consul." , // prepared query is not supported in v2
}
for _ , question := range questions {
m := new ( dns . Msg )
m . SetQuestion ( question , dns . TypeSRV )
in , _ , err := client . Exchange ( m , a . DNSAddr ( ) )
if err != nil {
t . Fatalf ( "err: %v" , err )
}
require . Equal ( t , 1 , len ( in . Ns ) , "Bad: %s" , in . String ( ) )
soaRec , ok := in . Ns [ 0 ] . ( * dns . SOA )
require . True ( t , ok , "Bad: %s" , in . Ns [ 0 ] . String ( ) )
require . EqualValues ( t , 0 , soaRec . Hdr . Ttl , "Bad: %s" , in . Ns [ 0 ] . String ( ) )
}
// Lookup workloads directly with a port.
for workloadName , dnsType := range map [ string ] uint16 {
"db-1" : dns . TypeA ,
"db-2" : dns . TypeA ,
"db-3" : dns . TypeAAAA ,
} {
for _ , question := range [ ] string {
fmt . Sprintf ( "%s.workload.default.ns.default.ap.consul." , workloadName ) ,
fmt . Sprintf ( "tcp.port.%s.workload.default.ns.default.ap.consul." , workloadName ) ,
fmt . Sprintf ( "admin.port.%s.workload.default.ns.default.ap.consul." , workloadName ) ,
fmt . Sprintf ( "mesh.port.%s.workload.default.ns.default.ap.consul." , workloadName ) ,
} {
workload := dbWorkloads [ workloadName ]
workloadHost := workload . Addresses [ 0 ] . Host
m := new ( dns . Msg )
m . SetQuestion ( question , dnsType )
in , _ , err := client . Exchange ( m , a . DNSAddr ( ) )
if err != nil {
t . Fatalf ( "err: %v" , err )
}
require . Equal ( t , 1 , len ( in . Answer ) , "Bad: %s" , in . String ( ) )
a := findAorAAAAForName ( t , in , in . Answer , question )
require . Equal ( t , workloadHost , a . AorAAAA . String ( ) , "Bad: %s" , a . Original . String ( ) )
require . EqualValues ( t , 0 , a . Hdr . Ttl , "Bad: %s" , a . Original . String ( ) )
}
}
// Lookup a non-existing workload, we should receive an NXDOMAIN response.
for _ , aType := range [ ] uint16 { dns . TypeA , dns . TypeAAAA } {
question := "unknown.workload.consul."
m := new ( dns . Msg )
m . SetQuestion ( question , aType )
in , _ , err := client . Exchange ( m , a . DNSAddr ( ) )
if err != nil {
t . Fatalf ( "err: %v" , err )
}
require . Equal ( t , 0 , len ( in . Answer ) , "Bad: %s" , in . String ( ) )
require . Equal ( t , dns . RcodeNameError , in . Rcode , "Bad: %s" , in . String ( ) )
}
}
}
func findSrvAnswerForTarget ( t * testing . T , in * dns . Msg , target string ) * dns . SRV {
t . Helper ( )
for _ , a := range in . Answer {
srvRec , ok := a . ( * dns . SRV )
if ok && srvRec . Target == target {
return srvRec
}
}
t . Fatalf ( "could not find SRV record for target: %s\n\n%s" , target , in . String ( ) )
return nil
}
func findAorAAAAForName ( t * testing . T , in * dns . Msg , rrs [ ] dns . RR , name string ) * dnsAOrAAAA {
t . Helper ( )
for _ , rr := range rrs {
a := newAOrAAAA ( t , rr )
if a . Hdr . Name == name {
return a
}
}
t . Fatalf ( "could not find A/AAAA record for name: %s\n\n%+v" , name , in . String ( ) )
return nil
}
func findAorAAAAForAddress ( t * testing . T , in * dns . Msg , rrs [ ] dns . RR , address string ) * dnsAOrAAAA {
t . Helper ( )
for _ , rr := range rrs {
a := newAOrAAAA ( t , rr )
if a . AorAAAA . String ( ) == address {
return a
}
}
t . Fatalf ( "could not find A/AAAA record for address: %s\n\n%+v" , address , in . String ( ) )
return nil
}
func readResource ( t retry . TestingTB , client pbresource . ResourceServiceClient , id * pbresource . ID , m proto . Message ) proto . Message {
t . Helper ( )
retry . Run ( t , func ( r * retry . R ) {
res , err := client . Read ( context . Background ( ) , & pbresource . ReadRequest { Id : id } )
if err != nil {
r . Fatalf ( "err: %v" , err )
}
data := res . GetResource ( )
require . NotEmpty ( r , data )
err = data . Data . UnmarshalTo ( m )
require . NoError ( r , err )
} )
return m
}
func toAny ( t retry . TestingTB , m proto . Message ) * anypb . Any {
t . Helper ( )
a , err := anypb . New ( m )
if err != nil {
t . Fatalf ( "could not convert proto to `any` message: %v" , err )
}
return a
}
// dnsAOrAAAA unifies A and AAAA records for simpler testing when the IP type doesn't matter.
type dnsAOrAAAA struct {
Original dns . RR
Hdr dns . RR_Header
AorAAAA net . IP
isAAAA bool
}
func newAOrAAAA ( t * testing . T , rr dns . RR ) * dnsAOrAAAA {
t . Helper ( )
if aRec , ok := rr . ( * dns . A ) ; ok {
return & dnsAOrAAAA {
Original : rr ,
Hdr : aRec . Hdr ,
AorAAAA : aRec . A ,
isAAAA : false ,
}
}
if aRec , ok := rr . ( * dns . AAAA ) ; ok {
return & dnsAOrAAAA {
Original : rr ,
Hdr : aRec . Hdr ,
AorAAAA : aRec . AAAA ,
isAAAA : true ,
}
}
t . Fatalf ( "Bad A or AAAA record: %#v" , rr )
return nil
}
func newDNSClient ( tcp bool ) * dns . Client {
c := new ( dns . Client )
// Use TCP to avoid truncation of larger responses and
// sidestep the default UDP size limit of 3 answers
// set by config.DefaultSource() in agent/config/default.go.
if tcp {
c . Net = "tcp"
}
return c
}