Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/autocar/preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ func TestPreflightServerRejectsInvalidConfiguration(t *testing.T) {
{"handshake timeout", "--handshake-timeout", []string{"--handshake-timeout", "-1s"}},
{"destination write timeout", "--destination-write-timeout", []string{"--destination-write-timeout", "-1s"}},
{"connections", "--max-connections", []string{"--max-connections", "0"}},
{"fallback above connection limit", "--max-client-fallback-connections", []string{"--max-connections", "1", "--max-client-connections", "1", "--max-streams", "3", "--max-client-fallback-connections", "2"}},
{"fallback above stream limit", "--max-client-fallback-connections", []string{"--max-streams", "1", "--max-client-fallback-connections", "2"}},
{"UDP sessions", "--max-client-udp-sessions", []string{"--max-udp-sessions", "1"}},
{"rate bound", "protocol maximum", []string{"--pacing", "fixed-rate", "--max-upload-mbps", "8000001", "--max-download-mbps", "1"}},
{"web missing root", "static cover", []string{"--protocol", "web", "--cover-root", missing}},
Expand Down
15 changes: 11 additions & 4 deletions cmd/autocar/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ func runServer(parent context.Context, args []string) error {
deniedCIDRsText := fs.String("deny-cidrs", "", "additional comma-separated denied destination CIDRs/IPs")
maxStreams := fs.Int("max-streams", 1024, "maximum active tunnel streams")
maxConnections := fs.Int("max-connections", 256, "global maximum accepted relay connections")
maxClientConnections := fs.Int("max-client-connections", 32, "maximum relay connections per source IPv4 or IPv6 /64")
maxClientFallbackConnections := fs.Int("max-client-fallback-connections", 0, "native protocol: maximum TLS fallback connections per source IPv4 or IPv6 /64; zero uses min(32, --max-streams)")
maxClientConnections := fs.Int("max-client-connections", 32, "maximum connections per source IPv4 or IPv6 /64: native QUIC, or shared web H2/H3")
maxClientFallbackConnections := fs.Int("max-client-fallback-connections", 0, "native protocol: maximum TLS fallback connections per source IPv4 or IPv6 /64; zero uses min(32, --max-streams, --max-connections)")
maxUDPSessions := fs.Int("max-udp-sessions", 256, "maximum live UDP associations across all QUIC clients")
maxClientUDPSessions := fs.Int("max-client-udp-sessions", 32, "maximum live UDP associations per source IPv4 or IPv6 /64")
maxUDPDestinations := fs.Int("max-udp-destinations", 64, "maximum numeric destinations authorized per UDP association")
Expand Down Expand Up @@ -90,8 +90,8 @@ func runServer(parent context.Context, args []string) error {
if *maxClientConnections <= 0 || *maxClientConnections > *maxConnections {
return errors.New("--max-client-connections must be positive and no greater than --max-connections")
}
if !*disableFallback && (*maxClientFallbackConnections < 0 || *maxClientFallbackConnections > *maxStreams) {
return errors.New("--max-client-fallback-connections must be zero or positive and no greater than --max-streams")
if !*disableFallback && (*maxClientFallbackConnections < 0 || *maxClientFallbackConnections > min(*maxStreams, *maxConnections)) {
return errors.New("--max-client-fallback-connections must be zero or positive and no greater than --max-streams or --max-connections")
}
if serverProtocol == "web" && *maxClientFallbackConnections != 0 {
return errors.New("--max-client-fallback-connections is valid only with --protocol=native; web H2/H3 share --max-client-connections")
Expand Down Expand Up @@ -233,6 +233,10 @@ func runServer(parent context.Context, args []string) error {
return nil
}

connectionAdmission, err := tunnel.NewConnectionAdmission(*maxConnections)
if err != nil {
return fmt.Errorf("configure connection admission: %w", err)
}
quicServer, err := tunnel.ListenQUIC(tunnel.QUICServerConfig{
Address: *listen,
Token: token,
Expand All @@ -248,6 +252,7 @@ func runServer(parent context.Context, args []string) error {
MaxConcurrentStreams: *maxStreams,
StreamAdmission: streamAdmission,
MaxConnections: *maxConnections,
ConnectionAdmission: connectionAdmission,
MaxClientConnections: *maxClientConnections,
MaxUDPSessions: *maxUDPSessions,
MaxClientUDPSessions: *maxClientUDPSessions,
Expand All @@ -274,6 +279,8 @@ func runServer(parent context.Context, args []string) error {
DestinationWriteTimeout: *destinationWriteTimeout,
MaxConcurrentStreams: *maxStreams,
StreamAdmission: streamAdmission,
MaxConnections: *maxConnections,
ConnectionAdmission: connectionAdmission,
MaxClientConnections: *maxClientFallbackConnections,
})
if err != nil {
Expand Down
147 changes: 147 additions & 0 deletions cmd/autocar/server_connection_admission_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package main

import (
"context"
"crypto/tls"
"errors"
"log/slog"
"net"
"testing"
"time"

"github.com/cppla/autocar/internal/protocol"
"github.com/cppla/autocar/internal/security"
quic "github.com/quic-go/quic-go"
)

func TestNativeServerMaxConnectionsIncludesTLSFallback(t *testing.T) {
clearPreflightEnvironment(t)
files := newPreflightFiles(t)
addresses := make(chan nativeListenerAddress, 2)
previousLogger := slog.Default()
slog.SetDefault(slog.New(&nativeAddressHandler{addresses: addresses}))
t.Cleanup(func() { slog.SetDefault(previousLogger) })
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
var serverErr error
go func() {
defer close(done)
serverErr = runServer(ctx, []string{
"--protocol=native", "--listen=127.0.0.1:0", "--tcp-listen=127.0.0.1:0",
"--cert", files.cert, "--key", files.key, "--token-file", files.token,
"--max-connections=1", "--max-client-connections=1", "--max-streams=3",
})
}()
t.Cleanup(func() {
cancel()
select {
case <-done:
if serverErr != nil {
t.Errorf("server shutdown: %v", serverErr)
}
case <-time.After(3 * time.Second):
t.Error("server did not stop")
}
})
listeners := make(map[string]string)
for len(listeners) < 2 {
select {
case listener := <-addresses:
listeners[listener.transport] = listener.address
case <-done:
t.Fatalf("server failed before listening: %v", serverErr)
case <-time.After(3 * time.Second):
t.Fatal("native listeners did not start")
}
}
roots, err := security.LoadCertPool(files.cert)
if err != nil {
t.Fatal(err)
}
clientTLS := &tls.Config{
MinVersion: tls.VersionTLS13, RootCAs: roots, ServerName: "relay.invalid",
NextProtos: []string{protocol.ALPN},
}
dialTLS := func() (*tls.Conn, error) {
return tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", listeners["tls"], clientTLS.Clone())
}
first, err := dialTLS()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = first.Close() })
// A verified handshake proves the accepted TLS connection is holding the
// connection budget, even though it has not consumed an active stream.
second, err := dialTLS()
if second != nil {
_ = second.Close()
}
if err == nil {
t.Fatal("--max-connections=1 admitted a second TLS fallback connection with --max-streams=3")
}
if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
t.Fatalf("second TLS connection timed out instead of being rejected: %v", err)
}
quicCtx, stopQUIC := context.WithTimeout(context.Background(), 2*time.Second)
defer stopQUIC()
q, err := quic.DialAddr(quicCtx, listeners["quic"], clientTLS.Clone(), nil)
if q != nil {
defer q.CloseWithError(0, "test cleanup")
select {
case <-q.Context().Done():
err = context.Cause(q.Context())
case <-quicCtx.Done():
t.Fatal("QUIC was admitted while TLS held the global connection budget")
}
}
var appErr *quic.ApplicationError
if !errors.As(err, &appErr) || appErr.ErrorMessage != "connection limit reached" {
t.Fatalf("QUIC rejection = %v, want global connection limit", err)
}
_ = first.Close()
deadline := time.Now().Add(3 * time.Second)
for {
replacement, err := dialTLS()
if err == nil {
_ = replacement.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("TLS connection slot was not returned after close: %v", err)
}
time.Sleep(5 * time.Millisecond)
}
}

type nativeListenerAddress struct {
transport string
address string
}

type nativeAddressHandler struct {
addresses chan<- nativeListenerAddress
}

func (h *nativeAddressHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *nativeAddressHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *nativeAddressHandler) WithGroup(string) slog.Handler { return h }
func (h *nativeAddressHandler) Handle(_ context.Context, record slog.Record) error {
if record.Message != "relay listener started" {
return nil
}
var listener nativeListenerAddress
record.Attrs(func(attribute slog.Attr) bool {
switch attribute.Key {
case "transport":
listener.transport = attribute.Value.String()
case "address":
listener.address = attribute.Value.String()
}
return true
})
select {
case h.addresses <- listener:
default:
}
return nil
}
11 changes: 8 additions & 3 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,14 @@ services. Add deployment-specific blocks with `--deny-cidrs` and
`--deny-ports`.

Resource controls include `--max-connections`, `--max-client-connections`,
`--max-streams` and `--max-client-fallback-connections`. `--max-streams` is one
global admission budget shared by the QUIC and TLS listeners; enabling fallback
does not double the configured stream capacity.
`--max-streams` and `--max-client-fallback-connections`. Native QUIC and TLS
listeners share one `--max-connections` budget for all accepted connections,
including unauthenticated connections and authenticated idle QUIC sessions.
`--max-streams` independently limits active streams across both listeners.
The per-source limits remain separate: `--max-client-connections` caps QUIC
sessions, while `--max-client-fallback-connections` caps TLS connections and
defaults to `min(32, --max-streams, --max-connections)`. An explicit fallback
limit cannot exceed either `--max-streams` or `--max-connections`.

QUIC UDP associations have separate bounded controls:

Expand Down
23 changes: 20 additions & 3 deletions internal/proxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,16 @@ func (s *HTTPServer) serveForward(w http.ResponseWriter, r *http.Request) {
return
}
defer response.Body.Close()
// Preserve connection-specific trailer names before stripping Connection.
// The body can add both trailer values and previously unknown names at EOF.
blockedTrailers := httpForwardTrailerNominations(response.Header, response.Trailer)
removeHopByHopHeaders(response.Header)
copyHeaders(w.Header(), response.Header)
declaredTrailers := make(map[string]bool, len(response.Trailer))
for key := range response.Trailer {
if !isHopByHopHeader(key) {
if httpForwardTrailerAllowed(key, blockedTrailers) {
key = textproto.CanonicalMIMEHeaderKey(key)
declaredTrailers[key] = true
w.Header().Add("Trailer", key)
}
}
Expand All @@ -305,11 +311,22 @@ func (s *HTTPServer) serveForward(w http.ResponseWriter, r *http.Request) {
// response; net/http handles the sentinel without logging a stack trace.
panic(http.ErrAbortHandler)
}
for key := range httpForwardTrailerNominations(response.Trailer) {
blockedTrailers[key] = true
}
// A newly blocked declared trailer must not inherit an earlier header value.
for key := range declaredTrailers {
w.Header().Del(key)
}
for key, values := range response.Trailer {
if isHopByHopHeader(key) {
if !httpForwardTrailerAllowed(key, blockedTrailers) {
continue
}
w.Header()[textproto.CanonicalMIMEHeaderKey(key)] = append([]string(nil), values...)
key = textproto.CanonicalMIMEHeaderKey(key)
if !declaredTrailers[key] {
key = http.TrailerPrefix + key
}
w.Header()[key] = append([]string(nil), values...)
}
}

Expand Down
Loading
Loading