Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 01:00 UTC
| 1 | // Copyright 2015 Matthew Holt and The Caddy Authors |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. |
| 5 | // You may obtain a copy of the License at |
| 6 | // |
| 7 | // http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | // |
| 9 | // Unless required by applicable law or agreed to in writing, software |
| 10 | // distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | // See the License for the specific language governing permissions and |
| 13 | // limitations under the License. |
| 14 | |
| 15 | package reverseproxy |
| 16 | |
| 17 | import ( |
| 18 | "context" |
| 19 | "crypto/tls" |
| 20 | "errors" |
| 21 | "fmt" |
| 22 | "net/http" |
| 23 | "net/url" |
| 24 | "strings" |
| 25 | "time" |
| 26 | |
| 27 | "github.com/dunglas/httpsfv" |
| 28 | "github.com/quic-go/quic-go" |
| 29 | "github.com/quic-go/quic-go/http3" |
| 30 | "github.com/quic-go/webtransport-go" |
| 31 | |
| 32 | "github.com/caddyserver/caddy/v2" |
| 33 | "github.com/caddyserver/caddy/v2/modules/caddyhttp" |
| 34 | ) |
| 35 | |
| 36 | // Go canonical MIME keys (http.CanonicalHeaderKey). Spec names are |
| 37 | // WT-Available-Protocols / WT-Protocol; HTTP/3 on the wire is lowercase. |
| 38 | const ( |
| 39 | wtAvailableProtocolsHeader = "Wt-Available-Protocols" |
| 40 | wtProtocolHeader = "Wt-Protocol" |
| 41 | ) |
| 42 | |
| 43 | // webtransportProtocol values are the :protocol pseudo-header tokens sent |
| 44 | // by a client that wants to establish a WebTransport session over an HTTP/3 |
| 45 | // Extended CONNECT. draft-15 (webtransport-go v0.11.0+) uses "webtransport-h3"; |
| 46 | // older draft clients use the legacy "webtransport" token, which the |
| 47 | // webtransport-go server still accepts. Detect both. |
| 48 | const ( |
| 49 | webtransportProtocol = "webtransport" |
| 50 | webtransportProtocolDraft15 = "webtransport-h3" |
| 51 | ) |
| 52 | |
| 53 | // webtransportWriter is the naked HTTP/3 response-writer shape that |
| 54 | // webtransport.Server.Upgrade type-asserts on. Caddy's |
| 55 | // UnwrapResponseWriterAs walks the ResponseWriter wrapper chain to this |
| 56 | // type before calling Upgrade. |
| 57 | type webtransportWriter interface { |
| 58 | http.ResponseWriter |
| 59 | http3.Settingser |
| 60 | http3.HTTPStreamer |
| 61 | } |
| 62 | |
| 63 | // isWebTransportExtendedConnect reports whether r is an HTTP/3 Extended |
| 64 | // CONNECT that requests a WebTransport session. Does not check whether |
| 65 | // WebTransport proxying is configured; callers gate on Handler state. |
| 66 | func isWebTransportExtendedConnect(r *http.Request) bool { |
| 67 | return r.ProtoMajor == 3 && r.Method == http.MethodConnect && |
| 68 | (r.Proto == webtransportProtocol || r.Proto == webtransportProtocolDraft15) |
| 69 | } |
| 70 | |
| 71 | // webTransportHijack runs inside reverseProxy in place of RoundTrip when |
| 72 | // the request is a WebTransport Extended CONNECT. The outer proxy loop |
| 73 | // has already resolved the upstream set, selected an upstream, filled |
| 74 | // DialInfo, published reverse_proxy.upstream.* placeholders, applied |
| 75 | // transport and user request-header ops, cloned the request, directed |
| 76 | // the request URL at the upstream, and bumped in-flight counters — so |
| 77 | // this function only does WT-specific plumbing: upstream WT dial, |
| 78 | // client upgrade, and session pumping. |
| 79 | // |
| 80 | // Error semantics match the outer loop's retry contract: |
| 81 | // - Pre-dial misconfiguration (WT not enabled on the server, writer |
| 82 | // stack unsupported, handler transport is not HTTP/3) returns |
| 83 | // terminalError — no upstream can fix these conditions. |
| 84 | // - Upstream dial failure returns DialError — safe to retry across |
| 85 | // upstreams because no client-visible bytes have been written. |
| 86 | // - Post-upgrade failures return terminalError because the 200 OK |
| 87 | // has been flushed and the stream is hijacked. |
| 88 | // |
| 89 | // Requests that reach this function are already known to be WebTransport; |
| 90 | // callers should gate with isWebTransportExtendedConnect. origReq is the |
| 91 | // downstream request (Upgrade / CheckOrigin); req is the upstream-directed |
| 92 | // clone (URL, header_up, SNI expansion). |
| 93 | func (h *Handler) webTransportHijack(rw http.ResponseWriter, req *http.Request, origReq *http.Request, repl *caddy.Replacer, di DialInfo, server *caddyhttp.Server) error { |
| 94 | wtServer, ok := server.WebTransportServer().(*webtransport.Server) |
| 95 | if !ok || wtServer == nil { |
| 96 | return terminalError{caddyhttp.Error(http.StatusInternalServerError, |
| 97 | errors.New("webtransport: server has webtransport disabled or HTTP/3 is not enabled"))} |
| 98 | } |
| 99 | |
| 100 | // Reach the naked http3 response writer so Upgrade's type assertions |
| 101 | // succeed through Caddy's wrapper chain. Done before dialing so we |
| 102 | // fail fast if the writer stack is unexpectedly incompatible. |
| 103 | naked, ok := caddyhttp.UnwrapResponseWriterAs[webtransportWriter](rw) |
| 104 | if !ok { |
| 105 | return terminalError{caddyhttp.Error(http.StatusInternalServerError, |
| 106 | errors.New("webtransport: response writer does not support WebTransport upgrade"))} |
| 107 | } |
| 108 | |
| 109 | // A WT CONNECT reached this handler because the parent server has |
| 110 | // webtransport enabled. The upstream dial still needs the HTTP/3 |
| 111 | // transport's TLS config (versions must include "3"). |
| 112 | ht, ok := h.Transport.(*HTTPTransport) |
| 113 | if !ok || ht.h3Transport == nil { |
| 114 | return terminalError{caddyhttp.Error(http.StatusBadGateway, |
| 115 | errors.New("webtransport: requires the http transport with versions [\"3\"]"))} |
| 116 | } |
| 117 | tlsCfg := ht.h3Transport.TLSClientConfig |
| 118 | if tlsCfg == nil { |
| 119 | tlsCfg = new(tls.Config) |
| 120 | } |
| 121 | |
| 122 | // Expand SNI placeholders (e.g. tls_server_name {http.request.host}) per |
| 123 | // session. The normal HTTP/3 path does this via a custom h3Transport.Dial |
| 124 | // hook (#7737); the WebTransport path dials through its own Transport and |
| 125 | // bypasses that hook, so expand here. Clone first — the transport's TLS |
| 126 | // config is shared across sessions and must not be mutated in place. |
| 127 | if strings.Contains(tlsCfg.ServerName, "{") { |
| 128 | tlsCfg = tlsCfg.Clone() |
| 129 | tlsCfg.ServerName = repl.ReplaceAll(tlsCfg.ServerName, "") |
| 130 | } |
| 131 | |
| 132 | // Dial the upstream BEFORE upgrading the client. If the upstream is |
| 133 | // unreachable or refuses the CONNECT, a proper 5xx goes back over the |
| 134 | // H3 stream and the client's Dial sees the real status — instead of |
| 135 | // an already-upgraded session closing immediately. DialError so the |
| 136 | // outer proxy loop can fail over to another upstream, same as any |
| 137 | // other dial failure. |
| 138 | // |
| 139 | // WebTransport over HTTP/3 always uses https; RequestURI preserves |
| 140 | // the request's encoded path and query. |
| 141 | // |
| 142 | // Application protocol negotiation (draft-ietf-webtrans-http3 §3.3) |
| 143 | // is relayed, not chosen here: parse the offer from the prepared |
| 144 | // request (so transport and header_up ops apply), put it on the |
| 145 | // upstream Transport, and copy the upstream's WT-Protocol onto the |
| 146 | // client 200. Strip the offer from a clone of the forwarded headers |
| 147 | // so the Transport remashals a spec-correct list. The proxy loop |
| 148 | // reuses this request on retries and only recopies headers when |
| 149 | // header ops are configured, so the original map has to stay intact |
| 150 | // for the next upstream. The shared webtransport.Server keeps |
| 151 | // ApplicationProtocols empty so Caddy does not independently select |
| 152 | // a protocol. |
| 153 | offered := parseWTAvailableProtocols(req.Header) |
| 154 | dialHdr := req.Header.Clone() |
| 155 | dialHdr.Del(wtAvailableProtocolsHeader) |
| 156 | upstreamURL := "https://" + di.Address + req.URL.RequestURI() |
| 157 | dialStart := time.Now() |
| 158 | upstreamResp, upstreamSess, err := dialUpstreamWebTransport(req.Context(), tlsCfg, upstreamURL, dialHdr, offered, req.Host) |
| 159 | if err != nil { |
| 160 | return DialError{fmt.Errorf("webtransport upstream dial: %w", err)} |
| 161 | } |
| 162 | defer upstreamResp.Body.Close() |
| 163 | latency := time.Since(dialStart) |
| 164 | |
| 165 | // Copy filtered upstream headers, then apply header_down. Upgrade |
| 166 | // flushes rw.Header() with the 200, so this must happen first. |
| 167 | applyWebTransportResponseHeaders(rw, upstreamResp, h, repl) |
| 168 | |
| 169 | clientSess, err := wtServer.Upgrade(naked, origReq) |
| 170 | if err != nil { |
| 171 | _ = upstreamSess.CloseWithError(0, "client upgrade failed") |
| 172 | return terminalError{caddyhttp.Error(http.StatusBadRequest, |
| 173 | fmt.Errorf("webtransport upgrade: %w", err))} |
| 174 | } |
| 175 | unregister := server.RegisterWebTransportSession(webTransportSessionCloser{sess: clientSess}) |
| 176 | defer unregister() |
| 177 | caddyhttp.RecordHijackedStatus(rw, http.StatusOK) |
| 178 | |
| 179 | runWebTransportPump(clientSess, upstreamSess, h.logger) |
| 180 | h.recordUpstreamRoundTrip(di, repl, upstreamResp.StatusCode, latency) |
| 181 | repl.Set("http.reverse_proxy.upstream.duration", time.Since(dialStart)) |
| 182 | repl.Set("http.reverse_proxy.upstream.duration_ms", time.Since(dialStart).Seconds()*1e3) |
| 183 | return nil |
| 184 | } |
| 185 | |
| 186 | // webTransportSessionCloser closes one downstream WebTransport session |
| 187 | // during server shutdown. io.Closer keeps *webtransport.Session out of |
| 188 | // caddyhttp's exported API. |
| 189 | type webTransportSessionCloser struct { |
| 190 | sess *webtransport.Session |
| 191 | } |
| 192 | |
| 193 | func (w webTransportSessionCloser) Close() error { |
| 194 | return w.sess.CloseWithError(0, "server shutting down") |
| 195 | } |
| 196 | |
| 197 | // dialUpstreamWebTransport opens a WebTransport session to the upstream at |
| 198 | // urlStr (an https URL), forwarding reqHdr as headers on the Extended |
| 199 | // CONNECT request. The returned session is owned by the caller and must be |
| 200 | // closed when no longer in use. Return-value order matches |
| 201 | // webtransport.Transport.Dial: (response, session, error). |
| 202 | // applicationProtocols is the client's WT-Available-Protocols offer, |
| 203 | // forwarded so the Transport will accept the upstream's WT-Protocol choice. |
| 204 | // host, if non-empty, is the prepared Host / :authority for the CONNECT |
| 205 | // (header_up Host); the QUIC dial still uses the URL host. |
| 206 | // |
| 207 | // EXPERIMENTAL: this helper is an internal building block for the upcoming |
| 208 | // WebTransport reverse-proxy transport. Shape and behavior may change. |
| 209 | func dialUpstreamWebTransport(ctx context.Context, tlsCfg *tls.Config, urlStr string, reqHdr http.Header, applicationProtocols []string, host string) (*http.Response, *webtransport.Session, error) { |
| 210 | u, err := url.Parse(urlStr) |
| 211 | if err != nil { |
| 212 | return nil, nil, err |
| 213 | } |
| 214 | dialAddr := u.Host |
| 215 | if host != "" { |
| 216 | // Transport.Dial always sets req.Host from the URL host, so put the |
| 217 | // prepared Host there. DialAddr keeps the QUIC dial on the real |
| 218 | // upstream address. |
| 219 | u.Host = host |
| 220 | urlStr = u.String() |
| 221 | } |
| 222 | d := &webtransport.Transport{ |
| 223 | TLSClientConfig: tlsCfg, |
| 224 | ApplicationProtocols: applicationProtocols, |
| 225 | QUICConfig: &quic.Config{ |
| 226 | EnableDatagrams: true, |
| 227 | EnableStreamResetPartialDelivery: true, |
| 228 | }, |
| 229 | DialAddr: func(ctx context.Context, _ string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) { |
| 230 | return quic.DialAddrEarly(ctx, dialAddr, tlsCfg, cfg) |
| 231 | }, |
| 232 | } |
| 233 | return finishUpstreamWebTransportDial(d.Dial(ctx, urlStr, reqHdr)) |
| 234 | } |
| 235 | |
| 236 | // finishUpstreamWebTransportDial closes a rejected dial. Transport.Dial |
| 237 | // returns a non-nil response with an error when the peer rejects the |
| 238 | // Extended CONNECT, and the body must be closed. The session is nil on |
| 239 | // that path; close it too if a future dial returns both. |
| 240 | func finishUpstreamWebTransportDial(rsp *http.Response, sess *webtransport.Session, err error) (*http.Response, *webtransport.Session, error) { |
| 241 | if err == nil { |
| 242 | return rsp, sess, nil |
| 243 | } |
| 244 | if rsp != nil && rsp.Body != nil { |
| 245 | _ = rsp.Body.Close() |
| 246 | } |
| 247 | if sess != nil { |
| 248 | _ = sess.CloseWithError(0, "") |
| 249 | } |
| 250 | return nil, nil, err |
| 251 | } |
| 252 | |
| 253 | // applyWebTransportResponseHeaders copies non-hop upstream response headers |
| 254 | // onto the client writer and then applies configured header_down ops, matching |
| 255 | // finalizeResponse for regular proxy responses. |
| 256 | func applyWebTransportResponseHeaders(rw http.ResponseWriter, upstream *http.Response, h *Handler, repl *caddy.Replacer) { |
| 257 | hdr := upstream.Header.Clone() |
| 258 | if hdr == nil { |
| 259 | hdr = make(http.Header) |
| 260 | } |
| 261 | removeConnectionHeaders(hdr) |
| 262 | for _, hop := range hopHeaders { |
| 263 | hdr.Del(hop) |
| 264 | } |
| 265 | if h.Headers != nil && h.Headers.Response != nil { |
| 266 | if h.Headers.Response.Require == nil || |
| 267 | h.Headers.Response.Require.Match(upstream.StatusCode, hdr) { |
| 268 | h.Headers.Response.ApplyTo(hdr, repl) |
| 269 | } |
| 270 | } |
| 271 | copyHeader(rw.Header(), hdr) |
| 272 | } |
| 273 | |
| 274 | // parseWTAvailableProtocols extracts application-protocol tokens from a |
| 275 | // WT-Available-Protocols structured-field list. Missing or malformed |
| 276 | // values are ignored, matching draft-ietf-webtrans-http3 (treat the |
| 277 | // field as absent). |
| 278 | func parseWTAvailableProtocols(h http.Header) []string { |
| 279 | vals := h.Values(wtAvailableProtocolsHeader) |
| 280 | if len(vals) == 0 { |
| 281 | return nil |
| 282 | } |
| 283 | list, err := httpsfv.UnmarshalList(vals) |
| 284 | if err != nil { |
| 285 | return nil |
| 286 | } |
| 287 | var out []string |
| 288 | for _, item := range list { |
| 289 | i, ok := item.(httpsfv.Item) |
| 290 | if !ok { |
| 291 | return nil |
| 292 | } |
| 293 | p, ok := i.Value.(string) |
| 294 | if !ok { |
| 295 | return nil |
| 296 | } |
| 297 | out = append(out, p) |
| 298 | } |
| 299 | return out |
| 300 | } |