github.com/scottwinkler/terraform@v0.11.6-0.20180329211809-05143987aea8/svchost/disco/disco.go (about)

     1  // Package disco handles Terraform's remote service discovery protocol.
     2  //
     3  // This protocol allows mapping from a service hostname, as produced by the
     4  // svchost package, to a set of services supported by that host and the
     5  // endpoint information for each supported service.
     6  package disco
     7  
     8  import (
     9  	"encoding/json"
    10  	"errors"
    11  	"io"
    12  	"io/ioutil"
    13  	"log"
    14  	"mime"
    15  	"net/http"
    16  	"net/url"
    17  	"time"
    18  
    19  	cleanhttp "github.com/hashicorp/go-cleanhttp"
    20  	"github.com/hashicorp/terraform/svchost"
    21  	"github.com/hashicorp/terraform/svchost/auth"
    22  )
    23  
    24  const (
    25  	discoPath        = "/.well-known/terraform.json"
    26  	maxRedirects     = 3                // arbitrary-but-small number to prevent runaway redirect loops
    27  	discoTimeout     = 11 * time.Second // arbitrary-but-small time limit to prevent UI "hangs" during discovery
    28  	maxDiscoDocBytes = 1 * 1024 * 1024  // 1MB - to prevent abusive services from using loads of our memory
    29  )
    30  
    31  var httpTransport = cleanhttp.DefaultPooledTransport() // overridden during tests, to skip TLS verification
    32  
    33  // Disco is the main type in this package, which allows discovery on given
    34  // hostnames and caches the results by hostname to avoid repeated requests
    35  // for the same information.
    36  type Disco struct {
    37  	hostCache map[svchost.Hostname]Host
    38  	credsSrc  auth.CredentialsSource
    39  
    40  	// Transport is a custom http.RoundTripper to use.
    41  	// A package default is used if this is nil.
    42  	Transport http.RoundTripper
    43  }
    44  
    45  func NewDisco() *Disco {
    46  	return &Disco{}
    47  }
    48  
    49  // SetCredentialsSource provides a credentials source that will be used to
    50  // add credentials to outgoing discovery requests, where available.
    51  //
    52  // If this method is never called, no outgoing discovery requests will have
    53  // credentials.
    54  func (d *Disco) SetCredentialsSource(src auth.CredentialsSource) {
    55  	d.credsSrc = src
    56  }
    57  
    58  // ForceHostServices provides a pre-defined set of services for a given
    59  // host, which prevents the receiver from attempting network-based discovery
    60  // for the given host. Instead, the given services map will be returned
    61  // verbatim.
    62  //
    63  // When providing "forced" services, any relative URLs are resolved against
    64  // the initial discovery URL that would have been used for network-based
    65  // discovery, yielding the same results as if the given map were published
    66  // at the host's default discovery URL, though using absolute URLs is strongly
    67  // recommended to make the configured behavior more explicit.
    68  func (d *Disco) ForceHostServices(host svchost.Hostname, services map[string]interface{}) {
    69  	if d.hostCache == nil {
    70  		d.hostCache = map[svchost.Hostname]Host{}
    71  	}
    72  	if services == nil {
    73  		services = map[string]interface{}{}
    74  	}
    75  	d.hostCache[host] = Host{
    76  		discoURL: &url.URL{
    77  			Scheme: "https",
    78  			Host:   string(host),
    79  			Path:   discoPath,
    80  		},
    81  		services: services,
    82  	}
    83  }
    84  
    85  // Discover runs the discovery protocol against the given hostname (which must
    86  // already have been validated and prepared with svchost.ForComparison) and
    87  // returns an object describing the services available at that host.
    88  //
    89  // If a given hostname supports no Terraform services at all, a non-nil but
    90  // empty Host object is returned. When giving feedback to the end user about
    91  // such situations, we say e.g. "the host <name> doesn't provide a module
    92  // registry", regardless of whether that is due to that service specifically
    93  // being absent or due to the host not providing Terraform services at all,
    94  // since we don't wish to expose the detail of whole-host discovery to an
    95  // end-user.
    96  func (d *Disco) Discover(host svchost.Hostname) Host {
    97  	if d.hostCache == nil {
    98  		d.hostCache = map[svchost.Hostname]Host{}
    99  	}
   100  	if cache, cached := d.hostCache[host]; cached {
   101  		return cache
   102  	}
   103  
   104  	ret := d.discover(host)
   105  	d.hostCache[host] = ret
   106  	return ret
   107  }
   108  
   109  // DiscoverServiceURL is a convenience wrapper for discovery on a given
   110  // hostname and then looking up a particular service in the result.
   111  func (d *Disco) DiscoverServiceURL(host svchost.Hostname, serviceID string) *url.URL {
   112  	return d.Discover(host).ServiceURL(serviceID)
   113  }
   114  
   115  // discover implements the actual discovery process, with its result cached
   116  // by the public-facing Discover method.
   117  func (d *Disco) discover(host svchost.Hostname) Host {
   118  	discoURL := &url.URL{
   119  		Scheme: "https",
   120  		Host:   string(host),
   121  		Path:   discoPath,
   122  	}
   123  
   124  	t := d.Transport
   125  	if t == nil {
   126  		t = httpTransport
   127  	}
   128  
   129  	client := &http.Client{
   130  		Transport: t,
   131  		Timeout:   discoTimeout,
   132  
   133  		CheckRedirect: func(req *http.Request, via []*http.Request) error {
   134  			log.Printf("[DEBUG] Service discovery redirected to %s", req.URL)
   135  			if len(via) > maxRedirects {
   136  				return errors.New("too many redirects") // (this error message will never actually be seen)
   137  			}
   138  			return nil
   139  		},
   140  	}
   141  
   142  	req := &http.Request{
   143  		Method: "GET",
   144  		URL:    discoURL,
   145  	}
   146  
   147  	if d.credsSrc != nil {
   148  		creds, err := d.credsSrc.ForHost(host)
   149  		if err == nil {
   150  			if creds != nil {
   151  				creds.PrepareRequest(req) // alters req to include credentials
   152  			}
   153  		} else {
   154  			log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err)
   155  		}
   156  	}
   157  
   158  	log.Printf("[DEBUG] Service discovery for %s at %s", host, discoURL)
   159  
   160  	ret := Host{
   161  		discoURL: discoURL,
   162  	}
   163  
   164  	resp, err := client.Do(req)
   165  	if err != nil {
   166  		log.Printf("[WARN] Failed to request discovery document: %s", err)
   167  		return ret // empty
   168  	}
   169  	if resp.StatusCode != 200 {
   170  		log.Printf("[WARN] Failed to request discovery document: %s", resp.Status)
   171  		return ret // empty
   172  	}
   173  
   174  	// If the client followed any redirects, we will have a new URL to use
   175  	// as our base for relative resolution.
   176  	ret.discoURL = resp.Request.URL
   177  
   178  	contentType := resp.Header.Get("Content-Type")
   179  	mediaType, _, err := mime.ParseMediaType(contentType)
   180  	if err != nil {
   181  		log.Printf("[WARN] Discovery URL has malformed Content-Type %q", contentType)
   182  		return ret // empty
   183  	}
   184  	if mediaType != "application/json" {
   185  		log.Printf("[DEBUG] Discovery URL returned Content-Type %q, rather than application/json", mediaType)
   186  		return ret // empty
   187  	}
   188  
   189  	// (this doesn't catch chunked encoding, because ContentLength is -1 in that case...)
   190  	if resp.ContentLength > maxDiscoDocBytes {
   191  		// Size limit here is not a contractual requirement and so we may
   192  		// adjust it over time if we find a different limit is warranted.
   193  		log.Printf("[WARN] Discovery doc response is too large (got %d bytes; limit %d)", resp.ContentLength, maxDiscoDocBytes)
   194  		return ret // empty
   195  	}
   196  
   197  	// If the response is using chunked encoding then we can't predict
   198  	// its size, but we'll at least prevent reading the entire thing into
   199  	// memory.
   200  	lr := io.LimitReader(resp.Body, maxDiscoDocBytes)
   201  
   202  	servicesBytes, err := ioutil.ReadAll(lr)
   203  	if err != nil {
   204  		log.Printf("[WARN] Error reading discovery document body: %s", err)
   205  		return ret // empty
   206  	}
   207  
   208  	var services map[string]interface{}
   209  	err = json.Unmarshal(servicesBytes, &services)
   210  	if err != nil {
   211  		log.Printf("[WARN] Failed to decode discovery document as a JSON object: %s", err)
   212  		return ret // empty
   213  	}
   214  
   215  	ret.services = services
   216  	return ret
   217  }
   218  
   219  // Forget invalidates any cached record of the given hostname. If the host
   220  // has no cache entry then this is a no-op.
   221  func (d *Disco) Forget(host svchost.Hostname) {
   222  	delete(d.hostCache, host)
   223  }
   224  
   225  // ForgetAll is like Forget, but for all of the hostnames that have cache entries.
   226  func (d *Disco) ForgetAll() {
   227  	d.hostCache = nil
   228  }