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 }