mirror of
https://github.com/caddyserver/caddy.git
synced 2026-11-11 19:37:55 +00:00
* reverseproxy: set a write deadline on websocket close messages Cleanup sends a Close control message to every attached websocket while holding the tunnel tracker lock, during a config reload. If the peer has stopped reading, the tunnel's copy goroutine is blocked writing to it and the close message waits behind that write forever. The reload never finishes and holds the config lock, so every later /load and /config request hangs until Caddy is restarted. Setting a write deadline before the close message unblocks both writes. The connection is closed right after, so the deadline is never cleared. * Add bot disclosure * reverseproxy: make close control timeout a const, harden stalled-peer test * Delete BOT_DISCLOSURE.md Signed-off-by: Francis Lavoie <lavofr@gmail.com> * reverseproxy: bound each batch of websocket close messages by one deadline The close loops are serial and hold the tunnel tracker lock, so a per-connection write timeout let N stalled peers hold a reload for N times the timeout. Compute one absolute deadline per batch instead, and skip the best-effort close frame on connections that cannot set a write deadline rather than risk the hang. Extended CONNECT streams gain SetWriteDeadline through http.ResponseController. For HTTP/1 upgrades, net/http returns the upstream socket inside a body that hides its deadline methods, so the upstream close message was never bounded; record the connection from the request's ClientTrace and write the close message to that socket. Restores the TODO about being the exclusive writer, per review. * reverseproxy: make websocket close tests deterministic Per review, the stalled-peer tests use net.Pipe instead of real TCP: it is unbuffered and lets one write through at a time, so a close message waits behind a stuck copy write without large allocations or sleeps. The batch test checks that graceful closes share one deadline instead of timing cleanup. The HTTP/1 upstream close test now runs a real upgrade through ServeHTTP and the transport, so it fails if the GotConn hook is removed. The choice of upstream close writer moves into upstreamCloseWriter, with a table test for the HTTP/2 and HTTP/3 case. * reverseproxy: record the upstream conn with atomic.Pointer Per review, store the connection from the GotConn hook in an atomic.Pointer[net.Conn] instead of a mutex-guarded holder type. Rename upstreamCloseWriter to upstreamWriteCloser. It returns io.ReadWriteCloser, since both the body and the recorded socket are one, so gracefulClose keeps its original io.ReadWriteCloser parameter. * reverseproxy: share one close deadline across detached trackers Per review, an upstream removal notifies every stopped tracker in turn, and each close loop holds its own lock, so a deadline per tracker let N trackers with stalled peers hold a reload for N times the timeout. Compute one absolute deadline in notifyDetachedTunnelTrackersOfUpstreamRemoval and pass it through to closeConnectionsForUpstream. Add a test with two stopped trackers holding stalled net.Pipe peers that asserts both graceful closes receive the same deadline. * reverseproxy: simplify the websocket close deadline change Fold the two tunnel close loops into closeConnectionsLocked, so the shared-deadline rule and its rationale live in one place. Replace the per-tracker closeWriteTimeout field, which production never varied, with a package variable that tests shorten. Record the upstream socket only for websocket requests, since only their graceful close reads it, and store a copy of the connection rather than the address of the hook's parameter. Use the package's caddy.CtxKey convention for the context key. Share the upgrade tunnel fixture between the half-close test and the upstream close message test.
965 lines
30 KiB
Go
965 lines
30 KiB
Go
// 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.
|
|
|
|
// Most of the code in this file was initially borrowed from the Go
|
|
// standard library and modified; It had this copyright notice:
|
|
// Copyright 2011 The Go Authors
|
|
|
|
package reverseproxy
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
weakrand "math/rand/v2"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
"golang.org/x/net/http/httpguts"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
|
)
|
|
|
|
type extendedConnectReadWriteCloser struct {
|
|
io.ReadCloser
|
|
http.ResponseWriter
|
|
}
|
|
|
|
func (rwc extendedConnectReadWriteCloser) Write(p []byte) (n int, err error) {
|
|
n, err = rwc.ResponseWriter.Write(p)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
//nolint:bodyclose
|
|
err = http.NewResponseController(rwc.ResponseWriter).Flush()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// SetWriteDeadline bounds writes to the extended CONNECT stream, so a peer
|
|
// that stopped reading cannot block the close message forever.
|
|
func (rwc extendedConnectReadWriteCloser) SetWriteDeadline(deadline time.Time) error {
|
|
//nolint:bodyclose
|
|
return http.NewResponseController(rwc.ResponseWriter).SetWriteDeadline(deadline)
|
|
}
|
|
|
|
// upstreamConnCtxKey is the context key for the *atomic.Pointer[net.Conn]
|
|
// recording the connection a websocket upgrade's round trip used.
|
|
const upstreamConnCtxKey caddy.CtxKey = "reverse_proxy.upstream_conn"
|
|
|
|
// upstreamWriteCloser returns where to write the close message to the
|
|
// upstream. The write must be bounded by a deadline, but for HTTP/1
|
|
// upgrades net/http hands the upstream socket back inside a body type that
|
|
// hides its deadline methods and writes straight through to the socket, so
|
|
// the close message goes to the socket recorded during the round trip
|
|
// instead. An HTTP/1 connection is dedicated to this request; HTTP/2 and
|
|
// HTTP/3 share theirs between streams, so their bodies are used as they are.
|
|
func upstreamWriteCloser(req *http.Request, res *http.Response, backConn io.ReadWriteCloser) io.ReadWriteCloser {
|
|
if res.ProtoMajor != 1 {
|
|
return backConn
|
|
}
|
|
recorded, ok := req.Context().Value(upstreamConnCtxKey).(*atomic.Pointer[net.Conn])
|
|
if !ok {
|
|
return backConn
|
|
}
|
|
if uc := recorded.Load(); uc != nil {
|
|
return *uc
|
|
}
|
|
return backConn
|
|
}
|
|
|
|
func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWriter, req *http.Request, res *http.Response, upstreamAddr string) {
|
|
// Until the tunnel takes ownership, every setup failure must close the
|
|
// backend body and any hijacked client connection. net/http no longer
|
|
// owns the client socket after a successful hijack.
|
|
var conn io.ReadWriteCloser
|
|
tunnelOwnsConnections := false
|
|
defer func() {
|
|
if !tunnelOwnsConnections {
|
|
res.Body.Close()
|
|
if conn != nil {
|
|
conn.Close()
|
|
}
|
|
}
|
|
}()
|
|
reqUpType := upgradeType(req.Header)
|
|
resUpType := upgradeType(res.Header)
|
|
|
|
// Taken from https://github.com/golang/go/commit/5c489514bc5e61ad9b5b07bd7d8ec65d66a0512a
|
|
// We know reqUpType is ASCII, it's checked by the caller.
|
|
if !asciiIsPrint(resUpType) {
|
|
if c := logger.Check(zapcore.DebugLevel, "backend tried to switch to invalid protocol"); c != nil {
|
|
c.Write(zap.String("backend_upgrade", resUpType))
|
|
}
|
|
return
|
|
}
|
|
if !asciiEqualFold(reqUpType, resUpType) {
|
|
if c := logger.Check(zapcore.DebugLevel, "backend tried to switch to unexpected protocol via Upgrade header"); c != nil {
|
|
c.Write(
|
|
zap.String("backend_upgrade", resUpType),
|
|
zap.String("requested_upgrade", reqUpType),
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
backConn, ok := res.Body.(io.ReadWriteCloser)
|
|
if !ok {
|
|
logger.Error("internal error: 101 switching protocols response with non-writable body")
|
|
return
|
|
}
|
|
|
|
// write header first, response headers should not be counted in size
|
|
// like the rest of handler chain.
|
|
copyHeader(rw.Header(), res.Header)
|
|
|
|
// Capture all h fields needed by the tunnel now, so that the Handler (h)
|
|
// is not referenced after this function returns (for HTTP/1.1 hijacked
|
|
// connections the tunnel runs in a detached goroutine).
|
|
tunnel := h.tunnelTracker
|
|
bufferSize := h.StreamBufferSize
|
|
streamTimeout := time.Duration(h.StreamTimeout)
|
|
|
|
var (
|
|
brw *bufio.ReadWriter
|
|
detached bool
|
|
)
|
|
// websocket over http2 or http3 if extended connect is enabled,
|
|
// assuming backend doesn't support this, the request will be
|
|
// modified to http1.1 upgrade
|
|
// TODO: once we can reliably detect backend support this, it can
|
|
// be removed for those backends
|
|
if body, ok := caddyhttp.GetVar(req.Context(), "extended_connect_websocket_body").(io.ReadCloser); ok {
|
|
// websocket over extended connect can't be detached. rw and req.Body
|
|
// are only valid while the handler goroutine is running
|
|
req.Body = body
|
|
rw.Header().Del("Upgrade")
|
|
rw.Header().Del("Connection")
|
|
rw.Header().Del("Sec-WebSocket-Accept")
|
|
rw.WriteHeader(http.StatusOK)
|
|
|
|
if c := logger.Check(zap.DebugLevel, "upgrading connection"); c != nil {
|
|
c.Write(zap.Int("http_version", req.ProtoMajor))
|
|
}
|
|
|
|
//nolint:bodyclose
|
|
flushErr := http.NewResponseController(rw).Flush()
|
|
if flushErr != nil {
|
|
if c := h.logger.Check(zap.ErrorLevel, "failed to flush extended_connect websocket response"); c != nil {
|
|
c.Write(zap.Error(flushErr))
|
|
}
|
|
return
|
|
}
|
|
conn = extendedConnectReadWriteCloser{req.Body, rw}
|
|
// bufio is not needed, use minimal buffer
|
|
brw = bufio.NewReadWriter(bufio.NewReaderSize(conn, 1), bufio.NewWriterSize(conn, 1))
|
|
} else {
|
|
normalizeWebsocketHeaders(rw.Header())
|
|
if h.StreamDetached {
|
|
detached = caddyhttp.DetachResponseWriterAfterHijack(rw, req, true)
|
|
if !detached {
|
|
// Some outer writers may have accepted detachment before an
|
|
// inner writer refused it. Restore attached byte accounting
|
|
// and keep the handler running for the lifetime of the tunnel.
|
|
caddyhttp.DetachResponseWriterAfterHijack(rw, req, false)
|
|
if c := logger.Check(zap.DebugLevel, "detaching connection failed; keeping stream attached"); c != nil {
|
|
c.Write(zap.String("tip", "check if your response writers have an Unwrap method or if already hijacked"))
|
|
}
|
|
}
|
|
}
|
|
rw.WriteHeader(res.StatusCode)
|
|
|
|
if c := logger.Check(zap.DebugLevel, "upgrading connection"); c != nil {
|
|
c.Write(zap.Int("http_version", req.ProtoMajor))
|
|
}
|
|
|
|
var hijackErr error
|
|
//nolint:bodyclose
|
|
conn, brw, hijackErr = http.NewResponseController(rw).Hijack()
|
|
if errors.Is(hijackErr, http.ErrNotSupported) {
|
|
if c := h.logger.Check(zap.ErrorLevel, "can't switch protocols using non-Hijacker ResponseWriter"); c != nil {
|
|
c.Write(zap.String("type", fmt.Sprintf("%T", rw)))
|
|
}
|
|
return
|
|
}
|
|
|
|
if hijackErr != nil {
|
|
if c := h.logger.Check(zap.ErrorLevel, "hijack failed on protocol switch"); c != nil {
|
|
c.Write(zap.Error(hijackErr))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := brw.Flush(); err != nil {
|
|
if c := logger.Check(zapcore.DebugLevel, "response flush"); c != nil {
|
|
c.Write(zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// There may be buffered data in the *bufio.Reader
|
|
// see: https://github.com/caddyserver/caddy/issues/6273
|
|
if buffered := brw.Reader.Buffered(); buffered > 0 {
|
|
data, _ := brw.Peek(buffered)
|
|
_, err := backConn.Write(data)
|
|
if err != nil {
|
|
if c := logger.Check(zapcore.DebugLevel, "backConn write failed"); c != nil {
|
|
c.Write(zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Register both connections with the tunnel tracker. We also try to
|
|
// gracefully close connections we recognize as websockets. We need to make
|
|
// sure the client connection messages (i.e. to upstream) are masked, so we
|
|
// need to know whether the connection is considered the server or the
|
|
// client side of the proxy. Note that gracefulClose must not capture h,
|
|
// since the tunnel may outlive the handler instance.
|
|
gracefulClose := func(conn io.ReadWriteCloser, isClient bool) func(time.Time) error {
|
|
if isWebsocket(req) {
|
|
return func(deadline time.Time) error {
|
|
return writeCloseControl(conn, isClient, deadline)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
deleteFrontConn := tunnel.registerConnection(conn, gracefulClose(conn, false), detached, upstreamAddr)
|
|
deleteBackConn := tunnel.registerConnection(backConn, gracefulClose(upstreamWriteCloser(req, res, backConn), true), detached, upstreamAddr)
|
|
if h.streamLogsSkipHandshake() {
|
|
caddyhttp.SetVar(req.Context(), caddyhttp.LogSkipVar, true)
|
|
}
|
|
repl := req.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
|
|
repl.Set("http.reverse_proxy.upgraded", true)
|
|
streamUUID, _ := repl.GetString("http.request.uuid")
|
|
streamFields := makeStreamLogFields(streamUUID)
|
|
streamLogger := h.streamLoggerForRequest(req)
|
|
streamLevel := h.streamLogLevel
|
|
finishMetrics := trackActiveStream(upstreamAddr)
|
|
|
|
start := time.Now()
|
|
|
|
tunnelOwnsConnections = true
|
|
if !detached {
|
|
handleUpgradeTunnel(
|
|
streamLogger,
|
|
streamLevel,
|
|
conn,
|
|
backConn,
|
|
deleteFrontConn,
|
|
deleteBackConn,
|
|
bufferSize,
|
|
streamTimeout,
|
|
start,
|
|
finishMetrics,
|
|
streamFields,
|
|
)
|
|
} else {
|
|
// start a new goroutine
|
|
go handleUpgradeTunnel(
|
|
streamLogger,
|
|
streamLevel,
|
|
conn,
|
|
backConn,
|
|
deleteFrontConn,
|
|
deleteBackConn,
|
|
bufferSize,
|
|
streamTimeout,
|
|
start,
|
|
finishMetrics,
|
|
streamFields,
|
|
)
|
|
}
|
|
}
|
|
|
|
// handleUpgradeTunnel returns when transfer is done.
|
|
func handleUpgradeTunnel(
|
|
streamLogger *zap.Logger,
|
|
streamLevel zapcore.Level,
|
|
conn io.ReadWriteCloser,
|
|
backConn io.ReadWriteCloser,
|
|
deleteFrontConn func(),
|
|
deleteBackConn func(),
|
|
bufferSize int,
|
|
streamTimeout time.Duration,
|
|
start time.Time,
|
|
finishMetrics func(result string, duration time.Duration, toBackend int64, fromBackend int64),
|
|
streamFields []zap.Field,
|
|
) {
|
|
defer deleteBackConn()
|
|
defer deleteFrontConn()
|
|
var (
|
|
wg sync.WaitGroup
|
|
toBackend int64
|
|
fromBackend int64
|
|
result string
|
|
)
|
|
|
|
// when a stream timeout is encountered, no error will be read from errc
|
|
// a buffer size of 2 will allow both the read and write goroutines to
|
|
// send the error and exit
|
|
// see: https://github.com/caddyserver/caddy/issues/7418
|
|
errc := make(chan error, 2)
|
|
spc := switchProtocolCopier{
|
|
user: conn,
|
|
backend: backConn,
|
|
wg: &wg,
|
|
bufferSize: bufferSize,
|
|
sent: &toBackend,
|
|
received: &fromBackend,
|
|
}
|
|
wg.Add(2)
|
|
|
|
var timeoutc <-chan time.Time
|
|
if streamTimeout > 0 {
|
|
timer := time.NewTimer(streamTimeout)
|
|
defer timer.Stop()
|
|
timeoutc = timer.C
|
|
}
|
|
|
|
go spc.copyToBackend(errc)
|
|
go spc.copyFromBackend(errc)
|
|
// Wait for both directions to finish. A clean EOF in one direction is a
|
|
// half-close of that direction only, not the end of the tunnel: the copier
|
|
// propagates it to the destination with CloseWrite and reports nil, so the
|
|
// other direction is left open and pending bytes can still drain. Anything
|
|
// else ends the tunnel: a copy error, a failed CloseWrite, or errCopyDone
|
|
// when the destination has no write half to close.
|
|
//
|
|
// net/http/httputil expresses the same wait as:
|
|
//
|
|
// err := <-errc
|
|
// if err == nil {
|
|
// err = <-errc
|
|
// }
|
|
//
|
|
// which cannot be used verbatim here because this handler also selects on the
|
|
// stream timeout, so the wait is repeated inside that select instead. Both
|
|
// accept the same sequences; the loop only additionally lets the timeout win
|
|
// at either wait.
|
|
//
|
|
// See https://github.com/caddyserver/caddy/issues/8026, and the same class
|
|
// fixed upstream in net/http/httputil (https://go.dev/issue/35892).
|
|
result = "closed"
|
|
copyLoop:
|
|
for range 2 {
|
|
select {
|
|
case err := <-errc:
|
|
if err != nil {
|
|
result = classifyStreamResult(err)
|
|
if c := streamLogger.Check(streamLevel, "streaming error"); c != nil && !errors.Is(err, errCopyDone) {
|
|
c.Write(zap.Error(err))
|
|
}
|
|
break copyLoop
|
|
}
|
|
case t := <-timeoutc:
|
|
result = "timeout"
|
|
if c := streamLogger.Check(streamLevel, "stream timed out"); c != nil {
|
|
c.Write(zap.Time("timeout", t))
|
|
}
|
|
break copyLoop
|
|
}
|
|
}
|
|
|
|
// Close both ends to unblock the still-running copy goroutine,
|
|
// then wait for it so byte counts are final before metrics/logging.
|
|
conn.Close()
|
|
backConn.Close()
|
|
wg.Wait()
|
|
|
|
finishMetrics(result, time.Since(start), toBackend, fromBackend)
|
|
if c := streamLogger.Check(streamLevel, "connection closed"); c != nil {
|
|
fields := append([]zap.Field{}, streamFields...)
|
|
fields = append(fields,
|
|
zap.Duration("duration", time.Since(start)),
|
|
zap.Int64("bytes_to_backend", toBackend),
|
|
zap.Int64("bytes_from_backend", fromBackend),
|
|
)
|
|
c.Write(fields...)
|
|
}
|
|
}
|
|
|
|
func classifyStreamResult(err error) string {
|
|
if err == nil ||
|
|
errors.Is(err, errCopyDone) ||
|
|
errors.Is(err, io.EOF) ||
|
|
errors.Is(err, net.ErrClosed) ||
|
|
errors.Is(err, context.Canceled) {
|
|
return "closed"
|
|
}
|
|
return "error"
|
|
}
|
|
|
|
func makeStreamLogFields(streamUUID string) []zap.Field {
|
|
fields := make([]zap.Field, 0, 1)
|
|
if streamUUID != "" {
|
|
fields = append(fields, zap.String("uuid", streamUUID))
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// flushInterval returns the p.FlushInterval value, conditionally
|
|
// overriding its value for a specific request/response.
|
|
func (h Handler) flushInterval(req *http.Request, res *http.Response, incremental bool) time.Duration {
|
|
// The upstream asked for the response to be forwarded incrementally.
|
|
// See RFC 10036: https://www.rfc-editor.org/rfc/rfc10036.html
|
|
if incremental || caddyhttp.IsIncremental(res.Header) {
|
|
return -1 // negative means immediately
|
|
}
|
|
|
|
resCTHeader := res.Header.Get("Content-Type")
|
|
resCT, _, err := mime.ParseMediaType(resCTHeader)
|
|
|
|
// For Server-Sent Events responses, flush immediately.
|
|
// The MIME type is defined in https://www.w3.org/TR/eventsource/#text-event-stream
|
|
if err == nil && resCT == "text/event-stream" {
|
|
return -1
|
|
}
|
|
|
|
// We might have the case of streaming for which Content-Length might be unset.
|
|
if res.ContentLength == -1 {
|
|
return -1
|
|
}
|
|
|
|
return time.Duration(h.FlushInterval)
|
|
}
|
|
|
|
func (h Handler) copyResponse(dst http.ResponseWriter, src io.Reader, flushInterval time.Duration, logger *zap.Logger) error {
|
|
var w io.Writer = dst
|
|
|
|
if flushInterval != 0 {
|
|
var mlwLogger *zap.Logger
|
|
if h.VerboseLogs {
|
|
mlwLogger = logger.Named("max_latency_writer")
|
|
} else {
|
|
mlwLogger = zap.NewNop()
|
|
}
|
|
mlw := &maxLatencyWriter{
|
|
dst: dst,
|
|
//nolint:bodyclose
|
|
flush: http.NewResponseController(dst).Flush,
|
|
latency: flushInterval,
|
|
logger: mlwLogger,
|
|
}
|
|
defer mlw.stop()
|
|
|
|
// set up initial timer so headers get flushed even if body writes are delayed
|
|
mlw.flushPending = true
|
|
mlw.t = time.AfterFunc(flushInterval, mlw.delayedFlush)
|
|
|
|
w = mlw
|
|
}
|
|
|
|
buf := streamingBufPool.Get().(*[]byte)
|
|
defer streamingBufPool.Put(buf)
|
|
|
|
var copyLogger *zap.Logger
|
|
if h.VerboseLogs {
|
|
copyLogger = logger
|
|
} else {
|
|
copyLogger = zap.NewNop()
|
|
}
|
|
|
|
_, err := h.copyBuffer(w, src, *buf, copyLogger)
|
|
return err
|
|
}
|
|
|
|
// errWritingDownstream wraps errors from writing the response to the client,
|
|
// to tell them apart from errors reading the response from the backend.
|
|
var errWritingDownstream = errors.New("writing")
|
|
|
|
// copyBuffer returns any write errors or non-EOF read errors, and the amount
|
|
// of bytes written.
|
|
func (h Handler) copyBuffer(dst io.Writer, src io.Reader, buf []byte, logger *zap.Logger) (int64, error) {
|
|
if len(buf) == 0 {
|
|
buf = make([]byte, defaultBufferSize)
|
|
}
|
|
var written int64
|
|
for {
|
|
logger.Debug("waiting to read from upstream")
|
|
nr, rerr := src.Read(buf)
|
|
logger := logger.With(zap.Int("read", nr))
|
|
if c := logger.Check(zapcore.DebugLevel, "read from upstream"); c != nil {
|
|
c.Write(zap.Error(rerr))
|
|
}
|
|
if rerr != nil && rerr != io.EOF && rerr != context.Canceled {
|
|
// TODO: this could be useful to know (indeed, it revealed an error in our
|
|
// fastcgi PoC earlier; but it's this single error report here that necessitates
|
|
// a function separate from io.CopyBuffer, since io.CopyBuffer does not distinguish
|
|
// between read or write errors; in a reverse proxy situation, write errors are not
|
|
// something we need to report to the client, but read errors are a problem on our
|
|
// end for sure. so we need to decide what we want.)
|
|
// p.logf("copyBuffer: ReverseProxy read error during body copy: %v", rerr)
|
|
if c := logger.Check(zapcore.ErrorLevel, "reading from backend"); c != nil {
|
|
c.Write(zap.Error(rerr))
|
|
}
|
|
}
|
|
if nr > 0 {
|
|
logger.Debug("writing to downstream")
|
|
nw, werr := dst.Write(buf[:nr])
|
|
if nw > 0 {
|
|
written += int64(nw)
|
|
}
|
|
if c := logger.Check(zapcore.DebugLevel, "wrote to downstream"); c != nil {
|
|
c.Write(
|
|
zap.Int("written", nw),
|
|
zap.Int64("written_total", written),
|
|
zap.Error(werr),
|
|
)
|
|
}
|
|
if werr != nil {
|
|
return written, fmt.Errorf("%w: %w", errWritingDownstream, werr)
|
|
}
|
|
if nr != nw {
|
|
return written, io.ErrShortWrite
|
|
}
|
|
}
|
|
if rerr != nil {
|
|
if rerr == io.EOF {
|
|
return written, nil
|
|
}
|
|
return written, fmt.Errorf("reading: %w", rerr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// openConnection maps an open connection to an optional function for graceful
|
|
// close and records which upstream address the connection is proxying to.
|
|
// Also tracks whether the connection is detached, which means it should only be
|
|
// closed when the upstream is removed from the config, not on every reload.
|
|
type openConnection struct {
|
|
conn io.ReadWriteCloser
|
|
gracefulClose func(deadline time.Time) error
|
|
detached bool
|
|
upstream string
|
|
}
|
|
|
|
// tunnelTracker tracks hijacked/upgraded connections for selective cleanup.
|
|
// This exists to detach the lifecycle of streaming connections from the proxy
|
|
// Handler and config, since we typically want them to survive past config reloads.
|
|
// It also allows for selective connection cleanup based on their attachment status.
|
|
type tunnelTracker struct {
|
|
connections map[io.ReadWriteCloser]openConnection
|
|
closeTimer *time.Timer
|
|
closeDelay time.Duration
|
|
stopped bool
|
|
mu sync.Mutex
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func newTunnelTracker(logger *zap.Logger, closeDelay time.Duration) *tunnelTracker {
|
|
return &tunnelTracker{
|
|
connections: make(map[io.ReadWriteCloser]openConnection),
|
|
closeDelay: closeDelay,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// registerConnection stores conn in the tracking map. The caller must invoke
|
|
// the returned del func when the connection is done.
|
|
func (ts *tunnelTracker) registerConnection(conn io.ReadWriteCloser, gracefulClose func(deadline time.Time) error, detached bool, upstream string) (del func()) {
|
|
ts.mu.Lock()
|
|
ts.connections[conn] = openConnection{conn, gracefulClose, detached, upstream}
|
|
ts.mu.Unlock()
|
|
return func() {
|
|
ts.mu.Lock()
|
|
delete(ts.connections, conn)
|
|
empty := len(ts.connections) == 0 && ts.stopped
|
|
if empty {
|
|
if ts.closeTimer != nil {
|
|
if ts.closeTimer.Stop() {
|
|
ts.logger.Debug("stopped streaming connections close timer - all connections are already closed")
|
|
}
|
|
ts.closeTimer = nil
|
|
}
|
|
}
|
|
ts.mu.Unlock()
|
|
if empty {
|
|
unregisterDetachedTunnelTrackers(ts)
|
|
}
|
|
}
|
|
}
|
|
|
|
// closeAttachedConnections closes all tracked attached connections.
|
|
func (ts *tunnelTracker) closeAttachedConnections() error {
|
|
ts.mu.Lock()
|
|
defer ts.mu.Unlock()
|
|
// detached connections are only closed when the upstream is gone from the config
|
|
return ts.closeConnectionsLocked(time.Now().Add(closeControlWriteTimeout), func(oc openConnection) bool {
|
|
return !oc.detached
|
|
})
|
|
}
|
|
|
|
// closeConnectionsLocked sends a close message to, then closes, every
|
|
// tracked connection that match selects. The caller must hold mu. Every
|
|
// graceful close shares one absolute deadline: the loop is serial and holds
|
|
// the lock, so per-connection timeouts would add up across stalled peers.
|
|
// The first error is returned.
|
|
func (ts *tunnelTracker) closeConnectionsLocked(deadline time.Time, match func(openConnection) bool) error {
|
|
var err error
|
|
for _, oc := range ts.connections {
|
|
if !match(oc) {
|
|
continue
|
|
}
|
|
if oc.gracefulClose != nil {
|
|
if gracefulErr := oc.gracefulClose(deadline); gracefulErr != nil && err == nil {
|
|
err = gracefulErr
|
|
}
|
|
}
|
|
if closeErr := oc.conn.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// cleanupAttachedConnections closes upgraded attached connections.
|
|
// Depending on closeDelay it does that either immediately or after a timer.
|
|
func (ts *tunnelTracker) cleanupAttachedConnections() error {
|
|
ts.mu.Lock()
|
|
// Mark the tracker stopped before upstream-removal notifications, even
|
|
// when attached connections will be closed after a delay.
|
|
ts.stopped = true
|
|
if len(ts.connections) == 0 {
|
|
ts.mu.Unlock()
|
|
unregisterDetachedTunnelTrackers(ts)
|
|
return nil
|
|
}
|
|
if ts.closeDelay == 0 {
|
|
ts.mu.Unlock()
|
|
return ts.closeAttachedConnections()
|
|
}
|
|
|
|
defer ts.mu.Unlock()
|
|
delay := ts.closeDelay
|
|
ts.closeTimer = time.AfterFunc(delay, func() {
|
|
if c := ts.logger.Check(zapcore.DebugLevel, "closing streaming connections after delay"); c != nil {
|
|
c.Write(zap.Duration("delay", delay))
|
|
}
|
|
err := ts.closeAttachedConnections()
|
|
if err != nil {
|
|
if c := ts.logger.Check(zapcore.ErrorLevel, "failed to close connections after delay"); c != nil {
|
|
c.Write(
|
|
zap.Error(err),
|
|
zap.Duration("delay", delay),
|
|
)
|
|
}
|
|
}
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// writeCloseControl sends a best-effort Close control message to the given
|
|
// WebSocket connection. Thanks to @pascaldekloe who provided inspiration
|
|
// from his simple implementation of this I was able to learn from at:
|
|
// github.com/pascaldekloe/websocket. Further work for handling masking
|
|
// taken from github.com/gorilla/websocket.
|
|
//
|
|
// The write must finish by deadline: a peer that stopped reading leaves the
|
|
// tunnel's copy goroutine stuck writing, and the close message would wait
|
|
// behind it forever while the caller holds the tunnel tracker lock. The
|
|
// connection is closed right after, so the deadline needs no reset.
|
|
// Connections that cannot set a write deadline are skipped instead.
|
|
func writeCloseControl(conn io.Writer, isClient bool, deadline time.Time) error {
|
|
dc, ok := conn.(interface{ SetWriteDeadline(time.Time) error })
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := dc.SetWriteDeadline(deadline); err != nil {
|
|
if errors.Is(err, http.ErrNotSupported) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Sources:
|
|
// https://github.com/pascaldekloe/websocket/blob/32050af67a5d/websocket.go#L119
|
|
// https://github.com/gorilla/websocket/blob/v1.5.0/conn.go#L413
|
|
|
|
// For now, we're not using a reason. We might later, though.
|
|
// The code handling the reason is left in
|
|
var reason string // max 123 bytes (control frame payload limit is 125; status code takes 2)
|
|
|
|
const closeMessage = 8
|
|
const finalBit = 1 << 7 // Frame header byte 0 bits from Section 5.2 of RFC 6455
|
|
const maskBit = 1 << 7 // Frame header byte 1 bits from Section 5.2 of RFC 6455
|
|
const goingAwayUpper uint8 = 1001 >> 8
|
|
const goingAwayLower uint8 = 1001 & 0xff
|
|
|
|
b0 := byte(closeMessage) | finalBit
|
|
b1 := byte(len(reason) + 2)
|
|
if isClient {
|
|
b1 |= maskBit
|
|
}
|
|
|
|
buf := make([]byte, 0, 127)
|
|
buf = append(buf, b0, b1)
|
|
msgLength := 4 + len(reason)
|
|
|
|
// Both branches below append the "going away" code and reason
|
|
appendMessage := func(buf []byte) []byte {
|
|
buf = append(buf, goingAwayUpper, goingAwayLower)
|
|
buf = append(buf, []byte(reason)...)
|
|
return buf
|
|
}
|
|
|
|
// When we're the client, we need to mask the message as per
|
|
// https://www.rfc-editor.org/rfc/rfc6455#section-5.3
|
|
if isClient {
|
|
key := newMaskKey()
|
|
buf = append(buf, key[:]...)
|
|
msgLength += len(key)
|
|
buf = appendMessage(buf)
|
|
maskBytes(key, 0, buf[2+len(key):])
|
|
} else {
|
|
buf = appendMessage(buf)
|
|
}
|
|
|
|
// simply best-effort, but return error for logging purposes
|
|
// TODO: we might need to ensure we are the exclusive writer by this point (io.Copy is stopped)?
|
|
_, err := conn.Write(buf[:msgLength])
|
|
return err
|
|
}
|
|
|
|
// closeControlWriteTimeout is how long one batch of graceful closes waits
|
|
// for peers to accept their close messages before giving up on them. It is
|
|
// a variable so tests can shorten it.
|
|
var closeControlWriteTimeout = 5 * time.Second
|
|
|
|
// Copied from https://github.com/gorilla/websocket/blob/v1.5.0/mask.go
|
|
func maskBytes(key [4]byte, pos int, b []byte) int {
|
|
// Mask one byte at a time for small buffers.
|
|
if len(b) < 2*wordSize {
|
|
for i := range b {
|
|
b[i] ^= key[pos&3]
|
|
pos++
|
|
}
|
|
return pos & 3
|
|
}
|
|
|
|
// Mask one byte at a time to word boundary.
|
|
if n := int(uintptr(unsafe.Pointer(&b[0]))) % wordSize; n != 0 {
|
|
n = wordSize - n
|
|
for i := range b[:n] {
|
|
b[i] ^= key[pos&3]
|
|
pos++
|
|
}
|
|
b = b[n:]
|
|
}
|
|
|
|
// Create aligned word size key.
|
|
var k [wordSize]byte
|
|
for i := range k {
|
|
k[i] = key[(pos+i)&3] // nolint:gosec // false positive, impossible to be out of bounds; see: https://github.com/securego/gosec/issues/1525
|
|
}
|
|
kw := *(*uintptr)(unsafe.Pointer(&k))
|
|
|
|
// Mask one word at a time.
|
|
n := (len(b) / wordSize) * wordSize
|
|
for i := 0; i < n; i += wordSize {
|
|
*(*uintptr)(unsafe.Add(unsafe.Pointer(&b[0]), i)) ^= kw
|
|
}
|
|
|
|
// Mask one byte at a time for remaining bytes.
|
|
b = b[n:]
|
|
for i := range b {
|
|
b[i] ^= key[pos&3]
|
|
pos++
|
|
}
|
|
|
|
return pos & 3
|
|
}
|
|
|
|
// Copied from https://github.com/gorilla/websocket/blob/v1.5.0/conn.go#L184
|
|
func newMaskKey() [4]byte {
|
|
n := weakrand.Uint32()
|
|
return [4]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)}
|
|
}
|
|
|
|
// isWebsocket returns true if r looks to be an upgrade request for WebSockets.
|
|
// It is a fairly naive check.
|
|
func isWebsocket(r *http.Request) bool {
|
|
return httpguts.HeaderValuesContainsToken(r.Header["Connection"], "upgrade") &&
|
|
httpguts.HeaderValuesContainsToken(r.Header["Upgrade"], "websocket")
|
|
}
|
|
|
|
// closeConnectionsForUpstream closes detached connections to the given
|
|
// upstream on stopped trackers. Attached connections keep their close delay.
|
|
// The deadline is shared so one upstream removal is bounded across every
|
|
// tracker it notifies.
|
|
func (ts *tunnelTracker) closeConnectionsForUpstream(addr string, deadline time.Time) error {
|
|
ts.mu.Lock()
|
|
defer ts.mu.Unlock()
|
|
if !ts.stopped {
|
|
return nil
|
|
}
|
|
return ts.closeConnectionsLocked(deadline, func(oc openConnection) bool {
|
|
return oc.detached && oc.upstream == addr
|
|
})
|
|
}
|
|
|
|
type maxLatencyWriter struct {
|
|
dst io.Writer
|
|
flush func() error
|
|
latency time.Duration // non-zero; negative means to flush immediately
|
|
|
|
mu sync.Mutex // protects t, flushPending, and dst.Flush
|
|
t *time.Timer
|
|
flushPending bool
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func (m *maxLatencyWriter) Write(p []byte) (n int, err error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
n, err = m.dst.Write(p)
|
|
if c := m.logger.Check(zapcore.DebugLevel, "wrote bytes"); c != nil {
|
|
c.Write(zap.Int("n", n), zap.Error(err))
|
|
}
|
|
if m.latency < 0 {
|
|
m.logger.Debug("flushing immediately")
|
|
//nolint:errcheck
|
|
m.flush()
|
|
return n, err
|
|
}
|
|
if m.flushPending {
|
|
m.logger.Debug("delayed flush already pending")
|
|
return n, err
|
|
}
|
|
if m.t == nil {
|
|
m.t = time.AfterFunc(m.latency, m.delayedFlush)
|
|
} else {
|
|
m.t.Reset(m.latency)
|
|
}
|
|
if c := m.logger.Check(zapcore.DebugLevel, "timer set for delayed flush"); c != nil {
|
|
c.Write(zap.Duration("duration", m.latency))
|
|
}
|
|
m.flushPending = true
|
|
return n, err
|
|
}
|
|
|
|
func (m *maxLatencyWriter) delayedFlush() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if !m.flushPending { // if stop was called but AfterFunc already started this goroutine
|
|
m.logger.Debug("delayed flush is not pending")
|
|
return
|
|
}
|
|
m.logger.Debug("delayed flush")
|
|
//nolint:errcheck
|
|
m.flush()
|
|
m.flushPending = false
|
|
}
|
|
|
|
func (m *maxLatencyWriter) stop() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.flushPending = false
|
|
if m.t != nil {
|
|
m.t.Stop()
|
|
}
|
|
}
|
|
|
|
// switchProtocolCopier exists so goroutines proxying data back and
|
|
// forth have nice names in stacks.
|
|
type switchProtocolCopier struct {
|
|
user, backend io.ReadWriteCloser
|
|
wg *sync.WaitGroup
|
|
bufferSize int
|
|
// sent and received accumulate byte counts for each direction.
|
|
// They are written before wg.Done() and read after wg.Wait(), so no
|
|
// additional synchronization is needed beyond the WaitGroup barrier.
|
|
sent *int64 // bytes copied to backend; must be non-nil
|
|
received *int64 // bytes copied from backend; must be non-nil
|
|
}
|
|
|
|
// errCopyDone is reported by a direction that ended at a clean EOF on a
|
|
// connection whose write half cannot be closed, so the end of that direction
|
|
// could not be propagated to the peer.
|
|
var errCopyDone = errors.New("hijacked connection copy complete")
|
|
|
|
func (c switchProtocolCopier) copyFromBackend(errc chan<- error) {
|
|
defer c.wg.Done()
|
|
n, err := io.CopyBuffer(c.user, c.backend, c.buffer())
|
|
*c.received = n
|
|
if err != nil {
|
|
errc <- err
|
|
return
|
|
}
|
|
|
|
// backend conn has reached EOF so propagate close write to user conn
|
|
if wc, ok := c.user.(interface{ CloseWrite() error }); ok {
|
|
errc <- wc.CloseWrite()
|
|
return
|
|
}
|
|
|
|
errc <- errCopyDone
|
|
}
|
|
|
|
func (c switchProtocolCopier) copyToBackend(errc chan<- error) {
|
|
defer c.wg.Done()
|
|
n, err := io.CopyBuffer(c.backend, c.user, c.buffer())
|
|
*c.sent = n
|
|
if err != nil {
|
|
errc <- err
|
|
return
|
|
}
|
|
|
|
// user conn has reached EOF so propagate close write to backend conn
|
|
if wc, ok := c.backend.(interface{ CloseWrite() error }); ok {
|
|
errc <- wc.CloseWrite()
|
|
return
|
|
}
|
|
|
|
errc <- errCopyDone
|
|
}
|
|
|
|
func (c switchProtocolCopier) buffer() []byte {
|
|
size := c.bufferSize
|
|
if size <= 0 {
|
|
size = defaultBufferSize
|
|
}
|
|
return make([]byte, size)
|
|
}
|
|
|
|
var streamingBufPool = sync.Pool{
|
|
New: func() any {
|
|
// The Pool's New function should generally only return pointer
|
|
// types, since a pointer can be put into the return interface
|
|
// value without an allocation
|
|
// - (from the package docs)
|
|
b := make([]byte, defaultBufferSize)
|
|
return &b
|
|
},
|
|
}
|
|
|
|
const (
|
|
defaultBufferSize = 32 * 1024
|
|
wordSize = int(unsafe.Sizeof(uintptr(0)))
|
|
)
|