github.com/outbrain/consul@v1.4.5/connect/resolver.go (about) 1 package connect 2 3 import ( 4 "context" 5 "fmt" 6 "math/rand" 7 "strings" 8 9 "github.com/hashicorp/consul/agent/connect" 10 "github.com/hashicorp/consul/api" 11 ) 12 13 // Resolver is the interface implemented by a service discovery mechanism to get 14 // the address and identity of an instance to connect to via Connect as a 15 // client. 16 type Resolver interface { 17 // Resolve returns a single service instance to connect to. Implementations 18 // may attempt to ensure the instance returned is currently available. It is 19 // expected that a client will re-dial on a connection failure so making an 20 // effort to return a different service instance each time where available 21 // increases reliability. The context passed can be used to impose timeouts 22 // which may or may not be respected by implementations that make network 23 // calls to resolve the service. The addr returned is a string in any valid 24 // form for passing directly to `net.Dial("tcp", addr)`. The certURI 25 // represents the identity of the service instance. It will be matched against 26 // the TLS certificate URI SAN presented by the server and the connection 27 // rejected if they don't match. 28 Resolve(ctx context.Context) (addr string, certURI connect.CertURI, err error) 29 } 30 31 // StaticResolver is a statically defined resolver. This can be used to Dial a 32 // known Connect endpoint without performing service discovery. 33 type StaticResolver struct { 34 // Addr is the network address (including port) of the instance. It must be 35 // the connect-enabled mTLS listener and may be a proxy in front of the actual 36 // target service process. It is a string in any valid form for passing 37 // directly to net.Dial("tcp", addr). 38 Addr string 39 40 // CertURL is the identity we expect the server to present in it's TLS 41 // certificate. It must be an exact URI string match or the connection will be 42 // rejected. 43 CertURI connect.CertURI 44 } 45 46 // Resolve implements Resolver by returning the static values. 47 func (sr *StaticResolver) Resolve(ctx context.Context) (string, connect.CertURI, error) { 48 return sr.Addr, sr.CertURI, nil 49 } 50 51 const ( 52 // ConsulResolverTypeService indicates resolving healthy service nodes. 53 ConsulResolverTypeService int = iota 54 55 // ConsulResolverTypePreparedQuery indicates resolving via prepared query. 56 ConsulResolverTypePreparedQuery 57 ) 58 59 // ConsulResolver queries Consul for a service instance. 60 type ConsulResolver struct { 61 // Client is the Consul API client to use. Must be non-nil or Resolve will 62 // panic. 63 Client *api.Client 64 65 // Namespace of the query target. 66 Namespace string 67 68 // Name of the query target. 69 Name string 70 71 // Type of the query target. Should be one of the defined ConsulResolverType* 72 // constants. Currently defaults to ConsulResolverTypeService. 73 Type int 74 75 // Datacenter to resolve in, empty indicates agent's local DC. 76 Datacenter string 77 } 78 79 // Resolve performs service discovery against the local Consul agent and returns 80 // the address and expected identity of a suitable service instance. 81 func (cr *ConsulResolver) Resolve(ctx context.Context) (string, connect.CertURI, error) { 82 switch cr.Type { 83 case ConsulResolverTypeService: 84 return cr.resolveService(ctx) 85 case ConsulResolverTypePreparedQuery: 86 return cr.resolveQuery(ctx) 87 default: 88 return "", nil, fmt.Errorf("unknown resolver type") 89 } 90 } 91 92 func (cr *ConsulResolver) resolveService(ctx context.Context) (string, connect.CertURI, error) { 93 health := cr.Client.Health() 94 95 svcs, _, err := health.Connect(cr.Name, "", true, cr.queryOptions(ctx)) 96 if err != nil { 97 return "", nil, err 98 } 99 100 if len(svcs) < 1 { 101 return "", nil, fmt.Errorf("no healthy instances found") 102 } 103 104 // Services are not shuffled by HTTP API, pick one at (pseudo) random. 105 idx := 0 106 if len(svcs) > 1 { 107 idx = rand.Intn(len(svcs)) 108 } 109 110 return cr.resolveServiceEntry(svcs[idx]) 111 } 112 113 func (cr *ConsulResolver) resolveQuery(ctx context.Context) (string, connect.CertURI, error) { 114 resp, _, err := cr.Client.PreparedQuery().Execute(cr.Name, cr.queryOptions(ctx)) 115 if err != nil { 116 return "", nil, err 117 } 118 119 svcs := resp.Nodes 120 if len(svcs) < 1 { 121 return "", nil, fmt.Errorf("no healthy instances found") 122 } 123 124 // Services are not shuffled by HTTP API, pick one at (pseudo) random. 125 idx := 0 126 if len(svcs) > 1 { 127 idx = rand.Intn(len(svcs)) 128 } 129 130 return cr.resolveServiceEntry(&svcs[idx]) 131 } 132 133 func (cr *ConsulResolver) resolveServiceEntry(entry *api.ServiceEntry) (string, connect.CertURI, error) { 134 addr := entry.Service.Address 135 if addr == "" { 136 addr = entry.Node.Address 137 } 138 port := entry.Service.Port 139 140 service := entry.Service.Proxy.DestinationServiceName 141 if entry.Service.Connect != nil && entry.Service.Connect.Native { 142 service = entry.Service.Service 143 } 144 if service == "" { 145 // Shouldn't happen but to protect against bugs in agent API returning bad 146 // service response... 147 return "", nil, fmt.Errorf("not a valid connect service") 148 } 149 150 // Generate the expected CertURI 151 certURI := &connect.SpiffeIDService{ 152 // No host since we don't validate trust domain here (we rely on x509 to 153 // prove trust). 154 Namespace: "default", 155 Datacenter: entry.Node.Datacenter, 156 Service: service, 157 } 158 159 return fmt.Sprintf("%s:%d", addr, port), certURI, nil 160 } 161 162 func (cr *ConsulResolver) queryOptions(ctx context.Context) *api.QueryOptions { 163 q := &api.QueryOptions{ 164 // We may make this configurable one day but we may also implement our own 165 // caching which is even more stale so... 166 AllowStale: true, 167 Datacenter: cr.Datacenter, 168 169 // For prepared queries 170 Connect: true, 171 } 172 return q.WithContext(ctx) 173 } 174 175 // ConsulResolverFromAddrFunc returns a function for constructing ConsulResolver 176 // from a consul DNS formatted hostname (e.g. foo.service.consul or 177 // foo.query.consul). 178 // 179 // Note, the returned ConsulResolver resolves the query via regular agent HTTP 180 // discovery API. DNS is not needed or used for discovery, only the hostname 181 // format re-used for consistency. 182 func ConsulResolverFromAddrFunc(client *api.Client) func(addr string) (Resolver, error) { 183 // Capture client dependency 184 return func(addr string) (Resolver, error) { 185 // Http clients might provide hostname and port 186 host := strings.ToLower(stripPort(addr)) 187 188 // For now we force use of `.consul` TLD regardless of the configured domain 189 // on the cluster. That's because we don't know that domain here and it 190 // would be really complicated to discover it inline here. We do however 191 // need to be able to distinguish a hostname with the optional datacenter 192 // segment which we can't do unambiguously if we allow arbitrary trailing 193 // domains. 194 domain := ".consul" 195 if !strings.HasSuffix(host, domain) { 196 return nil, fmt.Errorf("invalid Consul DNS domain: note Connect SDK " + 197 "currently requires use of .consul domain even if cluster is " + 198 "configured with a different domain.") 199 } 200 201 // Remove the domain suffix 202 host = host[0 : len(host)-len(domain)] 203 204 parts := strings.Split(host, ".") 205 numParts := len(parts) 206 207 r := &ConsulResolver{ 208 Client: client, 209 Namespace: "default", 210 } 211 212 // Note that 3 segments may be a valid DNS name like 213 // <tag>.<service>.service.consul but not one we support, it might also be 214 // <service>.service.<datacenter>.consul which we do want to support so we 215 // have to figure out if the last segment is supported keyword and if not 216 // check if the supported keyword is further up... 217 218 // To simplify logic for now, we must match one of the following (not domain 219 // is stripped): 220 // <name>.[service|query] 221 // <name>.[service|query].<dc> 222 if numParts < 2 || numParts > 3 || !supportedTypeLabel(parts[1]) { 223 return nil, fmt.Errorf("unsupported Consul DNS domain: must be either " + 224 "<name>.service[.<datacenter>].consul or " + 225 "<name>.query[.<datacenter>].consul") 226 } 227 228 if numParts == 3 { 229 // Must be datacenter case 230 r.Datacenter = parts[2] 231 } 232 233 // By know we must have a supported query type which means at least 2 234 // elements with first 2 being name, and type respectively. 235 r.Name = parts[0] 236 switch parts[1] { 237 case "service": 238 r.Type = ConsulResolverTypeService 239 case "query": 240 r.Type = ConsulResolverTypePreparedQuery 241 default: 242 // This should never happen (tm) unless the supportedTypeLabel 243 // implementation is changed and this switch isn't. 244 return nil, fmt.Errorf("invalid discovery type") 245 } 246 247 return r, nil 248 } 249 } 250 251 func supportedTypeLabel(label string) bool { 252 return label == "service" || label == "query" 253 } 254 255 // stripPort copied from net/url/url.go 256 func stripPort(hostport string) string { 257 colon := strings.IndexByte(hostport, ':') 258 if colon == -1 { 259 return hostport 260 } 261 if i := strings.IndexByte(hostport, ']'); i != -1 { 262 return strings.TrimPrefix(hostport[:i], "[") 263 } 264 return hostport[:colon] 265 }