From 260982b2dfc2cbf30c9b6e3a06f54e589344fc41 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 27 Feb 2020 20:56:24 -0700 Subject: reverse_proxy: Allow use of URL to specify scheme This makes it more convenient to configure quick proxies that use HTTPS but also introduces a lot of logical complexity. We have to do a lot of verification for consistency and errors. Path and query string is not supported (i.e. no rewriting). Scheme and port can be inferred from each other if HTTP(S)/80/443. If omitted, defaults to HTTP. Any explicit transport config must be consistent with the upstream schemes, and the upstream schemes must all match too. But, this change allows a config that used to require this: reverse_proxy example.com:443 { transport http { tls } } to be reduced to this: reverse_proxy https://example.com which is really nice syntactic sugar (and is reminiscent of Caddy 1). --- modules/caddyhttp/reverseproxy/caddyfile.go | 135 ++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 5 deletions(-) (limited to 'modules/caddyhttp/reverseproxy/caddyfile.go') diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index d08e7f1..e9962dc 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -15,7 +15,10 @@ package reverseproxy import ( + "net" "net/http" + "net/url" + "reflect" "strconv" "strings" "time" @@ -81,10 +84,95 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) // } // } // +// Proxy upstream addresses should be network dial addresses such +// as `host:port`, or a URL such as `scheme://host:port`. Scheme +// and port may be inferred from other parts of the address/URL; if +// either are missing, defaults to HTTP. func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + // currently, all backends must use the same scheme/protocol (the + // underlying JSON does not yet support per-backend transports) + var commonScheme string + + // we'll wait until the very end of parsing before + // validating and encoding the transport + var transport http.RoundTripper + var transportModuleName string + + // TODO: the logic in this function is kind of sensitive, we need + // to write tests before making any more changes to it + upstreamDialAddress := func(upstreamAddr string) (string, error) { + // slight hack, to ensure a non-URL parses correctly (simplifies our code paths) + const undefinedScheme = "undefined" + if !strings.Contains(upstreamAddr, "://") { + upstreamAddr = undefinedScheme + "://" + upstreamAddr + } + + // convenient way to get desired scheme, host, and port + toURL, err := url.Parse(upstreamAddr) + if err != nil { + return "", d.Errf("parsing upstream address: %v", err) + } + if toURL.Scheme == undefinedScheme { + toURL.Scheme = "" + } + + // there is currently no way to perform a URL rewrite between choosing + // a backend and proxying to it, so we cannot allow extra components + // in backend URLs + if toURL.Path != "" || toURL.RawQuery != "" || toURL.Fragment != "" { + return "", d.Err("for now, URLs for proxy upstreams only support scheme, host, and port components") + } + + // ensure the port and scheme aren't in conflict + urlPort := toURL.Port() + if toURL.Scheme == "http" && urlPort == "443" { + return "", d.Err("upstream address has conflicting scheme (http://) and port (:443, the HTTPS port)") + } + if toURL.Scheme == "https" && urlPort == "80" { + return "", d.Err("upstream address has conflicting scheme (https://) and port (:80, the HTTP port)") + } + + // dial addresses always need a port, so if no port was + // specified, assume the default ports for HTTP(S) + if urlPort == "" { + var toPort string + if toURL.Scheme == "" { + // if no port or scheme is specified, we assume HTTP + toPort = "80" + } else if toURL.Scheme == "https" { + toPort = "443" + } + toURL.Host = net.JoinHostPort(toURL.Host, toPort) + } + + // if port is known and scheme is not, set the scheme + if toURL.Scheme == "" { + if urlPort == "80" { + toURL.Scheme = "http" + } else if urlPort == "443" { + toURL.Scheme = "https" + } + } + + // the underlying JSON does not yet support different + // transports (protocols or schemes) to each backend, + // so we remember the last one we see and compare them + if commonScheme != "" && toURL.Scheme != commonScheme { + return "", d.Errf("for now, all proxy upstreams must use the same scheme (transport protocol); expecting '%s://' but got '%s://'", + commonScheme, toURL.Scheme) + } + commonScheme = toURL.Scheme + + return toURL.Host, nil + } + for d.Next() { for _, up := range d.RemainingArgs() { - h.Upstreams = append(h.Upstreams, &Upstream{Dial: up}) + dialAddr, err := upstreamDialAddress(up) + if err != nil { + return err + } + h.Upstreams = append(h.Upstreams, &Upstream{Dial: dialAddr}) } for d.NextBlock(0) { @@ -95,7 +183,11 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } for _, up := range args { - h.Upstreams = append(h.Upstreams, &Upstream{Dial: up}) + dialAddr, err := upstreamDialAddress(up) + if err != nil { + return err + } + h.Upstreams = append(h.Upstreams, &Upstream{Dial: dialAddr}) } case "lb_policy": @@ -392,8 +484,8 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { if h.TransportRaw != nil { return d.Err("transport already specified") } - name := d.Val() - mod, err := caddy.GetModule("http.reverse_proxy.transport." + name) + transportModuleName = d.Val() + mod, err := caddy.GetModule("http.reverse_proxy.transport." + transportModuleName) if err != nil { return d.Errf("getting transport module '%s': %v", mod, err) } @@ -409,7 +501,7 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { if !ok { return d.Errf("module %s is not a RoundTripper", mod) } - h.TransportRaw = caddyconfig.JSONModuleObject(rt, "protocol", name, nil) + transport = rt default: return d.Errf("unrecognized subdirective %s", d.Val()) @@ -417,6 +509,39 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } } + // if the scheme inferred from the backends' addresses is + // HTTPS, we will need a non-nil transport to enable TLS + if commonScheme == "https" && transport == nil { + transport = new(HTTPTransport) + transportModuleName = "http" + } + + // verify transport configuration, and finally encode it + if transport != nil { + // TODO: these two cases are identical, but I don't know how to reuse the code + switch ht := transport.(type) { + case *HTTPTransport: + if commonScheme == "https" && ht.TLS == nil { + ht.TLS = new(TLSConfig) + } + if ht.TLS != nil && commonScheme == "http" { + return d.Errf("upstream address scheme is HTTP but transport is configured for HTTP+TLS (HTTPS)") + } + + case *NTLMTransport: + if commonScheme == "https" && ht.TLS == nil { + ht.TLS = new(TLSConfig) + } + if ht.TLS != nil && commonScheme == "http" { + return d.Errf("upstream address scheme is HTTP but transport is configured for HTTP+TLS (HTTPS)") + } + } + + if !reflect.DeepEqual(transport, new(HTTPTransport)) { + h.TransportRaw = caddyconfig.JSONModuleObject(transport, "protocol", transportModuleName, nil) + } + } + return nil } -- cgit v1.2.3