From 8e515289cbde97fb7ac18a3d035e03f8d0c8befe Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Tue, 5 Nov 2019 16:29:10 -0700 Subject: [PATCH] reverse_proxy: Add support for NTLM --- .../caddyhttp/reverseproxy/httptransport.go | 54 ++-- modules/caddyhttp/reverseproxy/ntlm.go | 234 ++++++++++++++++++ .../caddyhttp/reverseproxy/reverseproxy.go | 10 +- 3 files changed, 272 insertions(+), 26 deletions(-) create mode 100644 modules/caddyhttp/reverseproxy/ntlm.go diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go index ea03dc86..38a904e2 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -35,15 +35,15 @@ func init() { // HTTPTransport is essentially a configuration wrapper for http.Transport. // It defines a JSON structure useful when configuring the HTTP transport -// for Caddy's reverse proxy. +// for Caddy's reverse proxy. It builds its http.Transport at Provision. type HTTPTransport struct { // TODO: It's possible that other transports (like fastcgi) might be // able to borrow/use at least some of these config fields; if so, - // move them into a type called CommonTransport and embed it + // maybe move them into a type called CommonTransport and embed it? TLS *TLSConfig `json:"tls,omitempty"` KeepAlive *KeepAlive `json:"keep_alive,omitempty"` Compression *bool `json:"compression,omitempty"` - MaxConnsPerHost int `json:"max_conns_per_host,omitempty"` // TODO: NOTE: we use our health check stuff to enforce max REQUESTS per host, but this is connections + MaxConnsPerHost int `json:"max_conns_per_host,omitempty"` DialTimeout caddy.Duration `json:"dial_timeout,omitempty"` FallbackDelay caddy.Duration `json:"dial_fallback_delay,omitempty"` ResponseHeaderTimeout caddy.Duration `json:"response_header_timeout,omitempty"` @@ -53,7 +53,7 @@ type HTTPTransport struct { ReadBufferSize int `json:"read_buffer_size,omitempty"` Versions []string `json:"versions,omitempty"` - RoundTripper http.RoundTripper `json:"-"` + Transport *http.Transport `json:"-"` } // CaddyModule returns the Caddy module information. @@ -64,12 +64,23 @@ func (HTTPTransport) CaddyModule() caddy.ModuleInfo { } } -// Provision sets up h.RoundTripper with a http.Transport +// Provision sets up h.Transport with a *http.Transport // that is ready to use. func (h *HTTPTransport) Provision(_ caddy.Context) error { if len(h.Versions) == 0 { h.Versions = []string{"1.1", "2"} } + + rt, err := h.newTransport() + if err != nil { + return err + } + h.Transport = rt + + return nil +} + +func (h *HTTPTransport) newTransport() (*http.Transport, error) { dialer := &net.Dialer{ Timeout: time.Duration(h.DialTimeout), FallbackDelay: time.Duration(h.FallbackDelay), @@ -107,14 +118,14 @@ func (h *HTTPTransport) Provision(_ caddy.Context) error { var err error rt.TLSClientConfig, err = h.TLS.MakeTLSClientConfig() if err != nil { - return fmt.Errorf("making TLS client config: %v", err) + return nil, fmt.Errorf("making TLS client config: %v", err) } } if h.KeepAlive != nil { dialer.KeepAlive = time.Duration(h.KeepAlive.ProbeInterval) - if enabled := h.KeepAlive.Enabled; enabled != nil { - rt.DisableKeepAlives = !*enabled + if h.KeepAlive.Enabled != nil { + rt.DisableKeepAlives = !*h.KeepAlive.Enabled } rt.MaxIdleConns = h.KeepAlive.MaxIdleConns rt.MaxIdleConnsPerHost = h.KeepAlive.MaxIdleConnsPerHost @@ -131,21 +142,30 @@ func (h *HTTPTransport) Provision(_ caddy.Context) error { } } - h.RoundTripper = rt - - return nil + return rt, nil } -// RoundTrip implements http.RoundTripper with h.RoundTripper. -func (h HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { - return h.RoundTripper.RoundTrip(req) +// RoundTrip implements http.RoundTripper. +func (h *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + h.setScheme(req) + return h.Transport.RoundTrip(req) +} + +// setScheme ensures that the outbound request req +// has the scheme set in its URL; the underlying +// http.Transport requires a scheme to be set. +func (h *HTTPTransport) setScheme(req *http.Request) { + if req.URL.Scheme == "" { + req.URL.Scheme = "http" + if h.TLS != nil { + req.URL.Scheme = "https" + } + } } // Cleanup implements caddy.CleanerUpper and closes any idle connections. func (h HTTPTransport) Cleanup() error { - if ht, ok := h.RoundTripper.(*http.Transport); ok { - ht.CloseIdleConnections() - } + h.Transport.CloseIdleConnections() return nil } diff --git a/modules/caddyhttp/reverseproxy/ntlm.go b/modules/caddyhttp/reverseproxy/ntlm.go new file mode 100644 index 00000000..06ee4f8f --- /dev/null +++ b/modules/caddyhttp/reverseproxy/ntlm.go @@ -0,0 +1,234 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// 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. + +package reverseproxy + +import ( + "context" + "fmt" + "net" + "net/http" + "sync" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +func init() { + caddy.RegisterModule(NTLMTransport{}) +} + +// NTLMTransport proxies HTTP+NTLM authentication is being used. +// It basically wraps HTTPTransport so that it is compatible with +// NTLM's HTTP-hostile requirements. Specifically, it will use +// HTTPTransport's single, default *http.Transport for all requests +// (unless the client's connection is already mapped to a different +// transport) until a request comes in with Authorization header +// that has "NTLM" or "Negotiate"; when that happens, NTLMTransport +// maps the client's connection (by its address, req.RemoteAddr) +// to a new transport that is used only by that downstream conn. +// When the upstream connection is closed, the mapping is deleted. +// This preserves NTLM authentication contexts by ensuring that +// client connections use the same upstream connection. It does +// hurt performance a bit, but that's NTLM for you. +// +// This transport also forces HTTP/1.1 and Keep-Alives in order +// for NTLM to succeed. +type NTLMTransport struct { + *HTTPTransport + + transports map[string]*http.Transport + transportsMu *sync.RWMutex +} + +// CaddyModule returns the Caddy module information. +func (NTLMTransport) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + Name: "http.handlers.reverse_proxy.transport.http_ntlm", + New: func() caddy.Module { return new(NTLMTransport) }, + } +} + +// Provision sets up the transport module. +func (n *NTLMTransport) Provision(ctx caddy.Context) error { + n.transports = make(map[string]*http.Transport) + n.transportsMu = new(sync.RWMutex) + + if n.HTTPTransport == nil { + n.HTTPTransport = new(HTTPTransport) + } + + // NTLM requires HTTP/1.1 + n.HTTPTransport.Versions = []string{"1.1"} + + // NLTM requires keep-alive + if n.HTTPTransport.KeepAlive != nil { + enabled := true + n.HTTPTransport.KeepAlive.Enabled = &enabled + } + + // set up the underlying transport, since we + // rely on it for the heavy lifting + err := n.HTTPTransport.Provision(ctx) + if err != nil { + return err + } + + return nil +} + +// RoundTrip implements http.RoundTripper. It basically wraps +// the underlying HTTPTransport.Transport in a way that preserves +// NTLM context by mapping transports/connections. Note that this +// method does not call n.HTTPTransport.RoundTrip (our own method), +// but the underlying n.HTTPTransport.Transport.RoundTrip (standard +// library's method). +func (n *NTLMTransport) RoundTrip(req *http.Request) (*http.Response, error) { + n.HTTPTransport.setScheme(req) + + // when the upstream connection is closed, make sure + // we close the downstream connection with the client + // when this request is done; we only do this if + // using a bound transport + closeDownstreamIfClosedUpstream := func() { + n.transportsMu.Lock() + if _, ok := n.transports[req.RemoteAddr]; !ok { + req.Close = true + } + n.transportsMu.Unlock() + } + + // first, see if this downstream connection is + // already bound to a particular transport + // (transports are abstractions over connections + // to our upstream, and NTLM auth requires + // preserving authentication state for separate + // connections over multiple roundtrips, sigh) + n.transportsMu.Lock() + transport, ok := n.transports[req.RemoteAddr] + if ok { + n.transportsMu.Unlock() + defer closeDownstreamIfClosedUpstream() + return transport.RoundTrip(req) + } + + // otherwise, start by assuming we will use + // the default transport that carries all + // normal/non-NTLM-authenticated requests + transport = n.HTTPTransport.Transport + + // but if this request begins the NTLM authentication + // process, we need to pin it to a specific transport + if requestHasAuth(req) { + var err error + transport, err = n.newTransport() + if err != nil { + return nil, fmt.Errorf("making new transport for %s: %v", req.RemoteAddr, err) + } + n.transports[req.RemoteAddr] = transport + defer closeDownstreamIfClosedUpstream() + } + n.transportsMu.Unlock() + + // finally, do the roundtrip with the transport we selected + return transport.RoundTrip(req) +} + +// newTransport makes an NTLM-compatible transport. +func (n *NTLMTransport) newTransport() (*http.Transport, error) { + // start with a regular HTTP transport + transport, err := n.HTTPTransport.newTransport() + if err != nil { + return nil, err + } + + // we need to wrap upstream connections so we can + // clean up in two ways when that connection is + // closed: 1) destroy the transport that housed + // this connection, and 2) use that as a signal + // to close the connection to the downstream. + wrappedDialContext := transport.DialContext + + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + conn2, err := wrappedDialContext(ctx, network, address) + if err != nil { + return nil, err + } + req := ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request) + conn := &unbinderConn{Conn: conn2, ntlm: n, clientAddr: req.RemoteAddr} + return conn, nil + } + + return transport, nil +} + +// Cleanup implements caddy.CleanerUpper and closes any idle connections. +func (n *NTLMTransport) Cleanup() error { + if err := n.HTTPTransport.Cleanup(); err != nil { + return err + } + + n.transportsMu.Lock() + for _, t := range n.transports { + t.CloseIdleConnections() + } + n.transports = make(map[string]*http.Transport) + n.transportsMu.Unlock() + + return nil +} + +// deleteTransportsForClient deletes (unmaps) transports that are +// associated with clientAddr (a req.RemoteAddr value). +func (n *NTLMTransport) deleteTransportsForClient(clientAddr string) { + n.transportsMu.Lock() + for key := range n.transports { + if key == clientAddr { + delete(n.transports, key) + } + } + n.transportsMu.Unlock() +} + +// requestHasAuth returns true if req has an Authorization +// header with values "NTLM" or "Negotiate". +func requestHasAuth(req *http.Request) bool { + for _, val := range req.Header["Authorization"] { + if val == "NTLM" || val == "Negotiate" { + return true + } + } + return false +} + +// unbinderConn is used to wrap upstream connections +// so that we know when they are closed and can clean +// up after that. +type unbinderConn struct { + net.Conn + clientAddr string + ntlm *NTLMTransport +} + +func (uc *unbinderConn) Close() error { + uc.ntlm.deleteTransportsForClient(uc.clientAddr) + return uc.Conn.Close() +} + +// Interface guards +var ( + _ caddy.Provisioner = (*NTLMTransport)(nil) + _ http.RoundTripper = (*NTLMTransport)(nil) + _ caddy.CleanerUpper = (*NTLMTransport)(nil) +) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index f1e9144e..77dc0054 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -311,15 +311,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht // This assumes that no mutations of the request are performed // by h during or after proxying. func (h Handler) prepareRequest(req *http.Request) error { - // as a special (but very common) case, if the transport - // is HTTP, then ensure the request has the proper scheme - // because incoming requests by default are lacking it - if req.URL.Scheme == "" { - req.URL.Scheme = "http" - if ht, ok := h.Transport.(*HTTPTransport); ok && ht.TLS != nil { - req.URL.Scheme = "https" - } - } + // most of this is borrowed from the Go std lib reverse proxy if req.ContentLength == 0 { req.Body = nil // Issue golang/go#16036: nil Body for http.Transport retries