terraform/svchost/disco/disco.go

232 lines
7.2 KiB
Go

// Package disco handles Terraform's remote service discovery protocol.
//
// This protocol allows mapping from a service hostname, as produced by the
// svchost package, to a set of services supported by that host and the
// endpoint information for each supported service.
package disco
import (
"encoding/json"
"errors"
"io"
"io/ioutil"
"log"
"mime"
"net/http"
"net/url"
"time"
cleanhttp "github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/terraform/svchost"
"github.com/hashicorp/terraform/svchost/auth"
)
const (
discoPath = "/.well-known/terraform.json"
maxRedirects = 3 // arbitrary-but-small number to prevent runaway redirect loops
discoTimeout = 11 * time.Second // arbitrary-but-small time limit to prevent UI "hangs" during discovery
maxDiscoDocBytes = 1 * 1024 * 1024 // 1MB - to prevent abusive services from using loads of our memory
)
var httpTransport = cleanhttp.DefaultPooledTransport() // overridden during tests, to skip TLS verification
// Disco is the main type in this package, which allows discovery on given
// hostnames and caches the results by hostname to avoid repeated requests
// for the same information.
type Disco struct {
hostCache map[svchost.Hostname]Host
credsSrc auth.CredentialsSource
// Transport is a custom http.RoundTripper to use.
// A package default is used if this is nil.
Transport http.RoundTripper
}
// NewDisco returns a new initialized Disco object.
func NewDisco() *Disco {
return &Disco{}
}
// SetCredentialsSource provides a credentials source that will be used to
// add credentials to outgoing discovery requests, where available.
//
// If this method is never called, no outgoing discovery requests will have
// credentials.
func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
d.credsSrc = src
}
// ForceHostServices provides a pre-defined set of services for a given
// host, which prevents the receiver from attempting network-based discovery
// for the given host. Instead, the given services map will be returned
// verbatim.
//
// When providing "forced" services, any relative URLs are resolved against
// the initial discovery URL that would have been used for network-based
// discovery, yielding the same results as if the given map were published
// at the host's default discovery URL, though using absolute URLs is strongly
// recommended to make the configured behavior more explicit.
func (d *Disco) ForceHostServices(host svchost.Hostname, services map[string]interface{}) {
if d.hostCache == nil {
d.hostCache = map[svchost.Hostname]Host{}
}
if services == nil {
services = map[string]interface{}{}
}
d.hostCache[host] = Host{
discoURL: &url.URL{
Scheme: "https",
Host: string(host),
Path: discoPath,
},
services: services,
}
}
// Discover runs the discovery protocol against the given hostname (which must
// already have been validated and prepared with svchost.ForComparison) and
// returns an object describing the services available at that host.
//
// If a given hostname supports no Terraform services at all, a non-nil but
// empty Host object is returned. When giving feedback to the end user about
// such situations, we say e.g. "the host <name> doesn't provide a module
// registry", regardless of whether that is due to that service specifically
// being absent or due to the host not providing Terraform services at all,
// since we don't wish to expose the detail of whole-host discovery to an
// end-user.
func (d *Disco) Discover(host svchost.Hostname) Host {
if d.hostCache == nil {
d.hostCache = map[svchost.Hostname]Host{}
}
if cache, cached := d.hostCache[host]; cached {
return cache
}
ret := d.discover(host)
d.hostCache[host] = ret
return ret
}
// DiscoverServiceURL is a convenience wrapper for discovery on a given
// hostname and then looking up a particular service in the result.
func (d *Disco) DiscoverServiceURL(host svchost.Hostname, serviceID string) *url.URL {
return d.Discover(host).ServiceURL(serviceID)
}
// discover implements the actual discovery process, with its result cached
// by the public-facing Discover method.
func (d *Disco) discover(host svchost.Hostname) Host {
discoURL := &url.URL{
Scheme: "https",
Host: host.String(),
Path: discoPath,
}
t := d.Transport
if t == nil {
t = httpTransport
}
client := &http.Client{
Transport: t,
Timeout: discoTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
log.Printf("[DEBUG] Service discovery redirected to %s", req.URL)
if len(via) > maxRedirects {
return errors.New("too many redirects") // (this error message will never actually be seen)
}
return nil
},
}
req := &http.Request{
Method: "GET",
URL: discoURL,
}
if d.credsSrc != nil {
creds, err := d.credsSrc.ForHost(host)
if err == nil {
if creds != nil {
creds.PrepareRequest(req) // alters req to include credentials
}
} else {
log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
}
}
log.Printf("[DEBUG] Service discovery for %s at %s", host, discoURL)
ret := Host{
discoURL: discoURL,
}
resp, err := client.Do(req)
if err != nil {
log.Printf("[WARN] Failed to request discovery document: %s", err)
return ret // empty
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Printf("[WARN] Failed to request discovery document: %s", resp.Status)
return ret // empty
}
// If the client followed any redirects, we will have a new URL to use
// as our base for relative resolution.
ret.discoURL = resp.Request.URL
contentType := resp.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
log.Printf("[WARN] Discovery URL has malformed Content-Type %q", contentType)
return ret // empty
}
if mediaType != "application/json" {
log.Printf("[DEBUG] Discovery URL returned Content-Type %q, rather than application/json", mediaType)
return ret // empty
}
// (this doesn't catch chunked encoding, because ContentLength is -1 in that case...)
if resp.ContentLength > maxDiscoDocBytes {
// Size limit here is not a contractual requirement and so we may
// adjust it over time if we find a different limit is warranted.
log.Printf("[WARN] Discovery doc response is too large (got %d bytes; limit %d)", resp.ContentLength, maxDiscoDocBytes)
return ret // empty
}
// If the response is using chunked encoding then we can't predict
// its size, but we'll at least prevent reading the entire thing into
// memory.
lr := io.LimitReader(resp.Body, maxDiscoDocBytes)
servicesBytes, err := ioutil.ReadAll(lr)
if err != nil {
log.Printf("[WARN] Error reading discovery document body: %s", err)
return ret // empty
}
var services map[string]interface{}
err = json.Unmarshal(servicesBytes, &services)
if err != nil {
log.Printf("[WARN] Failed to decode discovery document as a JSON object: %s", err)
return ret // empty
}
ret.services = services
return ret
}
// Forget invalidates any cached record of the given hostname. If the host
// has no cache entry then this is a no-op.
func (d *Disco) Forget(host svchost.Hostname) {
delete(d.hostCache, host)
}
// ForgetAll is like Forget, but for all of the hostnames that have cache entries.
func (d *Disco) ForgetAll() {
d.hostCache = nil
}