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