// Copyright 2018 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // +build go1.8 // The proxy package provides a record/replay HTTP proxy. It is designed to support // both an in-memory API (cloud.google.com/go/httpreplay) and a standalone server // (cloud.google.com/go/httpreplay/cmd/httpr). package proxy // See github.com/google/martian/cmd/proxy/main.go for the origin of much of this. import ( "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io/ioutil" "net" "net/http" "net/url" "strings" "time" "github.com/google/martian" "github.com/google/martian/fifo" "github.com/google/martian/har" "github.com/google/martian/httpspec" "github.com/google/martian/martianlog" "github.com/google/martian/mitm" ) // A Proxy is an HTTP proxy that supports recording or replaying requests. type Proxy struct { // The certificate that the proxy uses to participate in TLS. CACert *x509.Certificate // The URL of the proxy. URL *url.URL // Initial state of the client. Initial []byte mproxy *martian.Proxy filename string // for log logger *har.Logger // for recording only } // ForRecording returns a Proxy configured to record. func ForRecording(filename string, port int) (*Proxy, error) { p, err := newProxy(filename) if err != nil { return nil, err } // Configure the transport for the proxy's outgoing traffic. We MUST use // DialContext and not Dial. In Go 1.10, Setting Dial (but not DialContext) // disables HTTP2, and that gives different behavior than http.DefaultTransport. // (For example, GET // https://storage.googleapis.com/storage-library-test-bucket/gzipped-text.txt // with an "Accept-Encoding: gzip" header returns a Content-Length header with // HTTP2, but not HTTP1.) // We must also hide the type http.Transport from martian, because it looks for // http.Transport and sets the Dial field! p.mproxy.SetRoundTripper((*hideTransport)(&http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: time.Second, })) // Construct a group that performs the standard proxy stack of request/response // modifications. stack, _ := httpspec.NewStack("httpr") // second arg is an internal group that we don't need p.mproxy.SetRequestModifier(stack) p.mproxy.SetResponseModifier(stack) // Make a group for logging requests and responses. logGroup := fifo.NewGroup() skipAuth := skipLoggingByHost("accounts.google.com") logGroup.AddRequestModifier(skipAuth) logGroup.AddResponseModifier(skipAuth) p.logger = har.NewLogger() logGroup.AddRequestModifier(martian.RequestModifierFunc( func(req *http.Request) error { return withRedactedHeaders(req, p.logger) })) logGroup.AddResponseModifier(p.logger) stack.AddRequestModifier(logGroup) stack.AddResponseModifier(logGroup) // Ordinary debug logging. logger := martianlog.NewLogger() logger.SetDecode(true) stack.AddRequestModifier(logger) stack.AddResponseModifier(logger) if err := p.start(port); err != nil { return nil, err } return p, nil } type hideTransport http.Transport func (t *hideTransport) RoundTrip(req *http.Request) (*http.Response, error) { return (*http.Transport)(t).RoundTrip(req) } func newProxy(filename string) (*Proxy, error) { mproxy := martian.NewProxy() // Set up a man-in-the-middle configuration with a CA certificate so the proxy can // participate in TLS. x509c, priv, err := mitm.NewAuthority("cloud.google.com/go/httpreplay", "HTTPReplay Authority", time.Hour) if err != nil { return nil, err } mc, err := mitm.NewConfig(x509c, priv) if err != nil { return nil, err } mc.SetValidity(time.Hour) mc.SetOrganization("cloud.google.com/go/httpreplay") mc.SkipTLSVerify(false) if err != nil { return nil, err } mproxy.SetMITM(mc) return &Proxy{ mproxy: mproxy, CACert: x509c, filename: filename, }, nil } func (p *Proxy) start(port int) error { l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { return err } p.URL = &url.URL{Scheme: "http", Host: l.Addr().String()} go p.mproxy.Serve(l) return nil } // Transport returns an http.Transport for clients who want to talk to the proxy. func (p *Proxy) Transport() *http.Transport { caCertPool := x509.NewCertPool() caCertPool.AddCert(p.CACert) return &http.Transport{ TLSClientConfig: &tls.Config{RootCAs: caCertPool}, Proxy: func(*http.Request) (*url.URL, error) { return p.URL, nil }, } } // Close closes the proxy. If the proxy is recording, it also writes the log. func (p *Proxy) Close() error { p.mproxy.Close() if p.logger != nil { return p.writeLog() } return nil } type httprFile struct { Initial []byte HAR *har.HAR } func (p *Proxy) writeLog() error { f := httprFile{ Initial: p.Initial, HAR: p.logger.ExportAndReset(), } bytes, err := json.MarshalIndent(f, "", " ") if err != nil { return err } return ioutil.WriteFile(p.filename, bytes, 0600) // only accessible by owner } // Headers that may contain sensitive data (auth tokens, keys). var sensitiveHeaders = []string{ "Authorization", "X-Goog-Encryption-Key", // used by Cloud Storage for customer-supplied encryption "X-Goog-Copy-Source-Encryption-Key", // ditto } // withRedactedHeaders removes sensitive header contents before calling mod. func withRedactedHeaders(req *http.Request, mod martian.RequestModifier) error { // We have to change the headers, then log, then restore them. replaced := map[string]string{} for _, h := range sensitiveHeaders { if v := req.Header.Get(h); v != "" { replaced[h] = v req.Header.Set(h, "REDACTED") } } err := mod.ModifyRequest(req) for h, v := range replaced { req.Header.Set(h, v) } return err } // skipLoggingByHost disables logging for traffic to a particular host. type skipLoggingByHost string func (s skipLoggingByHost) ModifyRequest(req *http.Request) error { if strings.HasPrefix(req.Host, string(s)) { martian.NewContext(req).SkipLogging() } return nil } func (s skipLoggingByHost) ModifyResponse(res *http.Response) error { return s.ModifyRequest(res.Request) }