// 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" "fmt" "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" "github.com/hashicorp/terraform/version" ) const ( discoPath = "/.well-known/terraform.json" maxRedirects = 3 // arbitrary-but-small number to prevent runaway redirect loops discoTimeout = 4 * 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 userAgent = fmt.Sprintf("Terraform/%s (service discovery)", version.String()) 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.Transport to use. // A package default is used if this is nil. Transport *http.Transport } 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 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: string(host), 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 }, } var header = http.Header{} header.Set("User-Agent", userAgent) req := &http.Request{ Method: "GET", URL: discoURL, Header: header, } 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("[WARNING] 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("[WARNING] Failed to request discovery document: %s", err) return ret // empty } if resp.StatusCode != 200 { log.Printf("[WARNING] 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("[WARNING] 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("[WARNING] 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("[WARNING] 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("[WARNING] 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 }