mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-02 03:34:06 +09:00
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
This commit is contained in:
1 parent
5a4d366ce1
commit
28959cb27c
3 files changed
+177
-4
No files matched your search
@@ -162,6 +162,7 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`,
|
|||||||
- At startup, trim stored hostnames, endpoints and routes to the client-report limits and remove zoned endpoints. Hostinfo exceeding its size or tag limit is dropped until the next valid report
|
- At startup, trim stored hostnames, endpoints and routes to the client-report limits and remove zoned endpoints. Hostinfo exceeding its size or tag limit is dropped until the next valid report
|
||||||
- Pending interactive registrations keep only the hostname, OS and requested tags; other device details arrive with the first map request. Reauthentication preserves routes, services and endpoints
|
- Pending interactive registrations keep only the hostname, OS and requested tags; other device details arrive with the first map request. Reauthentication preserves routes, services and endpoints
|
||||||
- Web reauthentication preserves the discovery key so peers can still reach the device
|
- Web reauthentication preserves the discovery key so peers can still reach the device
|
||||||
|
- A stalled request body, or a Noise tunnel with no open request, is closed after 30 seconds
|
||||||
|
|
||||||
## 0.29.7 (202x-xx-xx)
|
## 0.29.7 (202x-xx-xx)
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -201,16 +201,28 @@ func (h *Headscale) NoiseUpgradeHandler(
|
|||||||
r.Post("/c2n", ns.NotImplementedHandler)
|
r.Post("/c2n", ns.NotImplementedHandler)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Serve returns once the listener is drained; the connection outlives it.
|
||||||
|
_ = newNoiseHTTPServer(r, types.HTTPTimeout).
|
||||||
|
Serve(netutil.NewOneConnListener(noiseConn, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newNoiseHTTPServer builds the server 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 set-dns waiting for its record to propagate.
|
||||||
|
func newNoiseHTTPServer(handler http.Handler, timeout time.Duration) *http.Server {
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Handler: r,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: types.HTTPTimeout,
|
ReadHeaderTimeout: timeout,
|
||||||
|
ReadTimeout: timeout,
|
||||||
|
IdleTimeout: timeout,
|
||||||
Protocols: new(http.Protocols),
|
Protocols: new(http.Protocols),
|
||||||
}
|
}
|
||||||
// Noise already encrypts, so HTTP/2 runs without TLS.
|
// Noise already encrypts, so HTTP/2 runs without TLS.
|
||||||
srv.Protocols.SetUnencryptedHTTP2(true)
|
srv.Protocols.SetUnencryptedHTTP2(true)
|
||||||
|
|
||||||
// Serve returns once the listener is drained; the connection outlives it.
|
return srv
|
||||||
_ = srv.Serve(netutil.NewOneConnListener(noiseConn, nil))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsupportedClientError(version tailcfg.CapabilityVersion) error {
|
func unsupportedClientError(version tailcfg.CapabilityVersion) error {
|
||||||
|
|||||||
@@ -10,10 +10,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"
|
||||||
@@ -32,6 +34,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"tailscale.com/control/ts2021"
|
"tailscale.com/control/ts2021"
|
||||||
"tailscale.com/net/netmon"
|
"tailscale.com/net/netmon"
|
||||||
|
"tailscale.com/net/netutil"
|
||||||
"tailscale.com/net/tsdial"
|
"tailscale.com/net/tsdial"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
@@ -2059,3 +2062,160 @@ 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{})
|
||||||
|
srv := newNoiseHTTPServer(handler, timeout)
|
||||||
|
srv.ConnState = func(_ net.Conn, state http.ConnState) {
|
||||||
|
if state == http.StateClosed {
|
||||||
|
close(closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() { _ = srv.Serve(netutil.NewOneConnListener(serverConn, nil)) }()
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// set-dns answers only once the nameservers serve the record, 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/set-dns",
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user