noise: time out stalled bodies and idle tunnels

The hijack drops the outer server's deadlines; any machine key gets a
tunnel.

Fixes ANT-2026-K903ZEUA

(cherry picked from commit 28959cb27c, adapted: 0.29 serves via http2.ServeConn, idle timeout set on http2.Server)
This commit is contained in:
Kristoffer Dalby committed 2026-10-30 10:33:17 +01:00
1 parent 3345493f92
commit bc3ac275b8
2 files changed
+177 -5

No files matched your search

+18 -5
View File
@@ -207,11 +207,7 @@ func (h *Headscale) NoiseUpgradeHandler(
r.Post("/c2n", ns.NotImplementedHandler) r.Post("/c2n", ns.NotImplementedHandler)
}) })
ns.httpBaseConfig = &http.Server{ ns.httpBaseConfig, ns.http2Server = newNoiseHTTPServers(r, types.HTTPTimeout)
Handler: r,
ReadHeaderTimeout: types.HTTPTimeout,
}
ns.http2Server = &http2.Server{}
ns.http2Server.ServeConn( ns.http2Server.ServeConn(
noiseConn, noiseConn,
@@ -221,6 +217,23 @@ func (h *Headscale) NoiseUpgradeHandler(
) )
} }
// newNoiseHTTPServers builds the servers for one Noise tunnel. The hijack
// drops the outer server's deadlines and any machine key gets a tunnel, so
// this is where a stalled request body (ReadTimeout) and a tunnel with no open
// stream (IdleTimeout) are bounded. WriteTimeout stays unset: it would cut
// long polls and a follow-up waiting for its registration. ServeConn takes the
// idle timeout from the [http2.Server], not from BaseConfig.
func newNoiseHTTPServers(
handler http.Handler,
timeout time.Duration,
) (*http.Server, *http2.Server) { //nolint:staticcheck // 0.29 serves tunnels via http2.ServeConn
return &http.Server{
Handler: handler,
ReadHeaderTimeout: timeout,
ReadTimeout: timeout,
}, &http2.Server{IdleTimeout: timeout} //nolint:staticcheck // see above
}
func unsupportedClientError(version tailcfg.CapabilityVersion) error { func unsupportedClientError(version tailcfg.CapabilityVersion) error {
return fmt.Errorf("%w: %s (%d)", ErrUnsupportedClientVersion, capver.TailscaleVersion(version), version) return fmt.Errorf("%w: %s (%d)", ErrUnsupportedClientVersion, capver.TailscaleVersion(version), version)
} }
+159
View File
@@ -8,10 +8,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -25,6 +27,7 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/net/http2"
"tailscale.com/control/ts2021" "tailscale.com/control/ts2021"
"tailscale.com/net/netmon" "tailscale.com/net/netmon"
"tailscale.com/net/tsdial" "tailscale.com/net/tsdial"
@@ -1544,3 +1547,159 @@ func TestRegistrationHandler_ValidatesClientReport(t *testing.T) {
assert.False(t, stored.Used) assert.False(t, stored.Used)
}) })
} }
// TestNoiseHTTPServerTimeouts pins the limits of the server inside a Noise
// tunnel. Any machine key gets a tunnel, so without them a stalled request
// body or an empty tunnel lives as long as the TCP connection.
func TestNoiseHTTPServerTimeouts(t *testing.T) {
t.Parallel()
const timeout = 200 * time.Millisecond
// serve runs handler the way NoiseUpgradeHandler serves a tunnel. It
// returns a client for the tunnel and a channel closed once the server
// has closed the connection.
serve := func(t *testing.T, handler http.Handler) (*http.Client, <-chan struct{}) {
t.Helper()
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
serverConn.Close()
clientConn.Close()
})
closed := make(chan struct{})
base, h2 := newNoiseHTTPServers(handler, timeout)
go func() {
// ServeConn returns once the server has closed the connection.
h2.ServeConn(serverConn, &http2.ServeConnOpts{BaseConfig: base}) //nolint:staticcheck // as NoiseUpgradeHandler
close(closed)
}()
protocols := new(http.Protocols)
protocols.SetUnencryptedHTTP2(true)
transport := &http.Transport{
Protocols: protocols,
DialContext: func(context.Context, string, string) (net.Conn, error) {
return clientConn, nil
},
}
t.Cleanup(transport.CloseIdleConnections)
return &http.Client{Transport: transport}, closed
}
t.Run("stalled body times out", func(t *testing.T) {
t.Parallel()
readErr := make(chan error, 1)
client, _ := serve(t, http.HandlerFunc(func(
_ http.ResponseWriter, r *http.Request,
) {
_, err := io.ReadAll(r.Body)
readErr <- err
}))
// One byte of body, then silence.
body, bodyWriter := io.Pipe()
t.Cleanup(func() { bodyWriter.Close() })
go func() { _, _ = bodyWriter.Write([]byte("{")) }()
ctx, cancel := context.WithTimeout(context.Background(), 10*timeout)
defer cancel()
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, "http://noise/machine/register", body,
)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err, "the handler must not stay blocked on a stalled body")
resp.Body.Close()
require.ErrorIs(t, <-readErr, os.ErrDeadlineExceeded)
})
// A register follow-up answers only once the user has authenticated,
// which can take longer than the timeout, and writes nothing until then.
t.Run("quiet answer past the timeout is delivered", func(t *testing.T) {
t.Parallel()
client, _ := serve(t, http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
_, _ = io.ReadAll(r.Body)
select {
case <-time.After(3 * timeout):
case <-r.Context().Done():
return
}
_, _ = io.WriteString(w, "{}")
}))
req, err := http.NewRequestWithContext(
context.Background(), http.MethodPost, "http://noise/machine/register",
strings.NewReader("{}"),
)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
got, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, "{}", string(got))
})
t.Run("long poll is kept, then the idle tunnel closes", func(t *testing.T) {
t.Parallel()
const chunks = 5
client, closed := serve(t, http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
_, _ = io.ReadAll(r.Body)
for range chunks {
_, _ = io.WriteString(w, "chunk\n")
w.(http.Flusher).Flush() //nolint:forcetypeassert // h2 writers flush
select {
case <-time.After(timeout):
case <-r.Context().Done():
return
}
}
}))
req, err := http.NewRequestWithContext(
context.Background(), http.MethodPost, "http://noise/machine/map",
strings.NewReader("{}"),
)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t, err)
got, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, strings.Repeat("chunk\n", chunks), string(got),
"a stream that outlives the timeout must not be cut")
select {
case <-closed:
case <-time.After(10 * timeout):
t.Fatal("the server kept a tunnel with no open stream")
}
})
}