-
Notifications
You must be signed in to change notification settings - Fork 9.6k
/
http_mirror_source.go
428 lines (381 loc) · 15 KB
/
http_mirror_source.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
package getproviders
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"net/http"
"net/url"
"path"
"strings"
"github.com/hashicorp/go-retryablehttp"
svchost "github.com/hashicorp/terraform-svchost"
svcauth "github.com/hashicorp/terraform-svchost/auth"
"golang.org/x/net/idna"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/httpclient"
"github.com/hashicorp/terraform/internal/logging"
"github.com/hashicorp/terraform/version"
)
// HTTPMirrorSource is a source that reads provider metadata from a provider
// mirror that is accessible over the HTTP provider mirror protocol.
type HTTPMirrorSource struct {
baseURL *url.URL
creds svcauth.CredentialsSource
httpClient *retryablehttp.Client
}
var _ Source = (*HTTPMirrorSource)(nil)
// NewHTTPMirrorSource constructs and returns a new network mirror source with
// the given base URL. The relative URL offsets defined by the HTTP mirror
// protocol will be resolve relative to the given URL.
//
// The given URL must use the "https" scheme, or this function will panic.
// (When the URL comes from user input, such as in the CLI config, it's the
// UI/config layer's responsibility to validate this and return a suitable
// error message for the end-user audience.)
func NewHTTPMirrorSource(baseURL *url.URL, creds svcauth.CredentialsSource) *HTTPMirrorSource {
httpClient := httpclient.New()
httpClient.Timeout = requestTimeout
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
// If we get redirected more than five times we'll assume we're
// in a redirect loop and bail out, rather than hanging forever.
if len(via) > 5 {
return fmt.Errorf("too many redirects")
}
return nil
}
return newHTTPMirrorSourceWithHTTPClient(baseURL, creds, httpClient)
}
func newHTTPMirrorSourceWithHTTPClient(baseURL *url.URL, creds svcauth.CredentialsSource, httpClient *http.Client) *HTTPMirrorSource {
if baseURL.Scheme != "https" {
panic("non-https URL for HTTP mirror")
}
// We borrow the retry settings and behaviors from the registry client,
// because our needs here are very similar to those of the registry client.
retryableClient := retryablehttp.NewClient()
retryableClient.HTTPClient = httpClient
retryableClient.RetryMax = discoveryRetry
retryableClient.RequestLogHook = requestLogHook
retryableClient.ErrorHandler = maxRetryErrorHandler
retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags())
return &HTTPMirrorSource{
baseURL: baseURL,
creds: creds,
httpClient: retryableClient,
}
}
// AvailableVersions retrieves the available versions for the given provider
// from the object's underlying HTTP mirror service.
func (s *HTTPMirrorSource) AvailableVersions(ctx context.Context, provider addrs.Provider) (VersionList, Warnings, error) {
log.Printf("[DEBUG] Querying available versions of provider %s at network mirror %s", provider.String(), s.baseURL.String())
endpointPath := path.Join(
provider.Hostname.String(),
provider.Namespace,
provider.Type,
"index.json",
)
statusCode, body, finalURL, err := s.get(ctx, endpointPath)
defer func() {
if body != nil {
body.Close()
}
}()
if err != nil {
return nil, nil, s.errQueryFailed(provider, err)
}
switch statusCode {
case http.StatusOK:
// Great!
case http.StatusNotFound:
return nil, nil, ErrProviderNotFound{
Provider: provider,
}
case http.StatusUnauthorized, http.StatusForbidden:
return nil, nil, s.errUnauthorized(finalURL)
default:
return nil, nil, s.errQueryFailed(provider, fmt.Errorf("server returned unsuccessful status %d", statusCode))
}
// If we got here then the response had status OK and so our body
// will be non-nil and should contain some JSON for us to parse.
type ResponseBody struct {
Versions map[string]struct{} `json:"versions"`
}
var bodyContent ResponseBody
dec := json.NewDecoder(body)
if err := dec.Decode(&bodyContent); err != nil {
return nil, nil, s.errQueryFailed(provider, fmt.Errorf("invalid response content from mirror server: %s", err))
}
if len(bodyContent.Versions) == 0 {
return nil, nil, nil
}
ret := make(VersionList, 0, len(bodyContent.Versions))
for versionStr := range bodyContent.Versions {
version, err := ParseVersion(versionStr)
if err != nil {
log.Printf("[WARN] Ignoring invalid %s version string %q in provider mirror response", provider, versionStr)
continue
}
ret = append(ret, version)
}
ret.Sort()
return ret, nil, nil
}
// PackageMeta retrieves metadata for the requested provider package
// from the object's underlying HTTP mirror service.
func (s *HTTPMirrorSource) PackageMeta(ctx context.Context, provider addrs.Provider, version Version, target Platform) (PackageMeta, error) {
log.Printf("[DEBUG] Finding package URL for %s v%s on %s via network mirror %s", provider.String(), version.String(), target.String(), s.baseURL.String())
endpointPath := path.Join(
provider.Hostname.String(),
provider.Namespace,
provider.Type,
version.String()+".json",
)
statusCode, body, finalURL, err := s.get(ctx, endpointPath)
defer func() {
if body != nil {
body.Close()
}
}()
if err != nil {
return PackageMeta{}, s.errQueryFailed(provider, err)
}
switch statusCode {
case http.StatusOK:
// Great!
case http.StatusNotFound:
// A 404 Not Found for a version we previously saw in index.json is
// a protocol error, so we'll report this as "query failed.
return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("provider mirror does not have archive index for previously-reported %s version %s", provider, version))
case http.StatusUnauthorized, http.StatusForbidden:
return PackageMeta{}, s.errUnauthorized(finalURL)
default:
return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("server returned unsuccessful status %d", statusCode))
}
// If we got here then the response had status OK and so our body
// will be non-nil and should contain some JSON for us to parse.
type ResponseArchiveMeta struct {
RelativeURL string `json:"url"`
Hashes []string
}
type ResponseBody struct {
Archives map[string]*ResponseArchiveMeta `json:"archives"`
}
var bodyContent ResponseBody
dec := json.NewDecoder(body)
if err := dec.Decode(&bodyContent); err != nil {
return PackageMeta{}, s.errQueryFailed(provider, fmt.Errorf("invalid response content from mirror server: %s", err))
}
archiveMeta, ok := bodyContent.Archives[target.String()]
if !ok {
return PackageMeta{}, ErrPlatformNotSupported{
Provider: provider,
Version: version,
Platform: target,
MirrorURL: s.baseURL,
}
}
relURL, err := url.Parse(archiveMeta.RelativeURL)
if err != nil {
return PackageMeta{}, s.errQueryFailed(
provider,
fmt.Errorf("provider mirror returned invalid URL %q: %s", archiveMeta.RelativeURL, err),
)
}
absURL := finalURL.ResolveReference(relURL)
ret := PackageMeta{
Provider: provider,
Version: version,
TargetPlatform: target,
Location: PackageHTTPURL(absURL.String()),
Filename: path.Base(absURL.Path),
}
// A network mirror might not provide any hashes at all, in which case
// the package has no source-defined authentication whatsoever.
if len(archiveMeta.Hashes) > 0 {
hashes := make([]Hash, 0, len(archiveMeta.Hashes))
for _, hashStr := range archiveMeta.Hashes {
hash, err := ParseHash(hashStr)
if err != nil {
return PackageMeta{}, s.errQueryFailed(
provider,
fmt.Errorf("provider mirror returned invalid provider hash %q: %s", hashStr, err),
)
}
hashes = append(hashes, hash)
}
ret.Authentication = NewPackageHashAuthentication(target, hashes)
}
return ret, nil
}
// ForDisplay returns a string description of the source for user-facing output.
func (s *HTTPMirrorSource) ForDisplay(provider addrs.Provider) string {
return "provider mirror at " + s.baseURL.String()
}
// mirrorHost extracts the hostname portion of the configured base URL and
// returns it as a svchost.Hostname, normalized in the usual ways.
//
// If the returned error is non-nil then the given hostname doesn't comply
// with the IETF RFC 5891 section 5.3 and 5.4 validation rules, and thus cannot
// be interpreted as a valid Terraform service host. The IDNA validation errors
// are unfortunately usually not very user-friendly, but they are also
// relatively rare because the IDNA normalization rules are quite tolerant.
func (s *HTTPMirrorSource) mirrorHost() (svchost.Hostname, error) {
return svchostFromURL(s.baseURL)
}
// mirrorHostCredentials returns the HostCredentials, if any, for the hostname
// included in the mirror base URL.
//
// It might return an error if the mirror base URL is invalid, or if the
// credentials lookup itself fails.
func (s *HTTPMirrorSource) mirrorHostCredentials() (svcauth.HostCredentials, error) {
hostname, err := s.mirrorHost()
if err != nil {
return nil, fmt.Errorf("invalid provider mirror base URL %s: %s", s.baseURL.String(), err)
}
if s.creds == nil {
// No host-specific credentials, then.
return nil, nil
}
return s.creds.ForHost(hostname)
}
// get is the shared functionality for querying a JSON index from a mirror.
//
// It only handles the raw HTTP request. The "body" return value is the
// reader from the response if and only if the response status code is 200 OK
// and the Content-Type is application/json. In all other cases it's nil.
// If body is non-nil then the caller must close it after reading it.
//
// If the "finalURL" return value is not empty then it's the URL that actually
// produced the returned response, possibly after following some redirects.
func (s *HTTPMirrorSource) get(ctx context.Context, relativePath string) (statusCode int, body io.ReadCloser, finalURL *url.URL, error error) {
endpointPath, err := url.Parse(relativePath)
if err != nil {
// Should never happen because the caller should validate all of the
// components it's including in the path.
return 0, nil, nil, err
}
endpointURL := s.baseURL.ResolveReference(endpointPath)
req, err := retryablehttp.NewRequest("GET", endpointURL.String(), nil)
if err != nil {
return 0, nil, endpointURL, err
}
req = req.WithContext(ctx)
req.Request.Header.Set(terraformVersionHeader, version.String())
creds, err := s.mirrorHostCredentials()
if err != nil {
return 0, nil, endpointURL, fmt.Errorf("failed to determine request credentials: %s", err)
}
if creds != nil {
// Note that if the initial requests gets redirected elsewhere
// then the credentials will still be included in the new request,
// even if they are on a different hostname. This is intentional
// and consistent with how we handle credentials for other
// Terraform-native services, because the user model is to configure
// credentials for the "friendly hostname" they configured, not for
// whatever hostname ends up ultimately serving the request as an
// implementation detail.
creds.PrepareRequest(req.Request)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return 0, nil, endpointURL, err
}
defer func() {
// If we're not returning the body then we'll close it
// before we return.
if body == nil {
resp.Body.Close()
}
}()
// After this point, our final URL return value should always be the
// one from resp.Request, because that takes into account any redirects
// we followed along the way.
finalURL = resp.Request.URL
if resp.StatusCode == http.StatusOK {
// If and only if we get an OK response, we'll check that the response
// type is JSON and return the body reader.
ct := resp.Header.Get("Content-Type")
mt, params, err := mime.ParseMediaType(ct)
if err != nil {
return 0, nil, finalURL, fmt.Errorf("response has invalid Content-Type: %s", err)
}
if mt != "application/json" {
return 0, nil, finalURL, fmt.Errorf("response has invalid Content-Type: must be application/json")
}
for name := range params {
// The application/json content-type has no defined parameters,
// but some servers are configured to include a redundant "charset"
// parameter anyway, presumably out of a sense of completeness.
// We'll ignore them but warn that we're ignoring them in case the
// subsequent parsing fails due to the server trying to use an
// unsupported character encoding. (RFC 7159 defines its own
// JSON-specific character encoding rules.)
log.Printf("[WARN] Network mirror returned %q as part of its JSON content type, which is not defined. Ignoring.", name)
}
body = resp.Body
}
return resp.StatusCode, body, finalURL, nil
}
func (s *HTTPMirrorSource) errQueryFailed(provider addrs.Provider, err error) error {
if err == context.Canceled {
// This one has a special error type so that callers can
// handle it in a different way.
return ErrRequestCanceled{}
}
return ErrQueryFailed{
Provider: provider,
Wrapped: err,
MirrorURL: s.baseURL,
}
}
func (s *HTTPMirrorSource) errUnauthorized(finalURL *url.URL) error {
hostname, err := svchostFromURL(finalURL)
if err != nil {
// Again, weird but we'll tolerate it.
return fmt.Errorf("invalid credentials for %s", finalURL)
}
return ErrUnauthorized{
Hostname: hostname,
// We can't easily tell from here whether we had credentials or
// not, so for now we'll just assume we did because "host rejected
// the given credentials" is, hopefully, still understandable in
// the event that there were none. (If this ends up being confusing
// in practice then we'll need to do some refactoring of how
// we handle credentials in this source.)
HaveCredentials: true,
}
}
func svchostFromURL(u *url.URL) (svchost.Hostname, error) {
raw := u.Host
// When "friendly hostnames" appear in Terraform-specific identifiers we
// typically constrain their syntax more strictly than the
// Internationalized Domain Name specifications call for, such as
// forbidding direct use of punycode, but in this case we're just
// working with a standard http: or https: URL and so we'll first use the
// IDNA "lookup" rules directly, with no additional notational constraints,
// to effectively normalize away the differences that would normally
// produce an error.
var portPortion string
if colonPos := strings.Index(raw, ":"); colonPos != -1 {
raw, portPortion = raw[:colonPos], raw[colonPos:]
}
// HTTPMirrorSource requires all URLs to be https URLs, because running
// a network mirror over HTTP would potentially transmit any configured
// credentials in cleartext. Therefore we don't need to do any special
// handling of default ports here, because svchost.Hostname already
// considers the absense of a port to represent the standard HTTPS port
// 443, and will normalize away an explicit specification of port 443
// in svchost.ForComparison below.
normalized, err := idna.Display.ToUnicode(raw)
if err != nil {
return svchost.Hostname(""), err
}
// If ToUnicode succeeded above then "normalized" is now a hostname in the
// normalized IDNA form, with any direct punycode already interpreted and
// the case folding and other normalization rules applied. It should
// therefore now be accepted by svchost.ForComparison with no additional
// errors, but the port portion can still potentially be invalid.
return svchost.ForComparison(normalized + portPortion)
}