From 0aae4a8397d078570b5c99fcfd2ff1c903b02da7 Mon Sep 17 00:00:00 2001 From: waffen29 Date: Sat, 25 Jul 2026 19:54:29 +0300 Subject: [PATCH] home: add IPv6 support for encrypted listeners --- internal/home/config.go | 2 +- internal/home/config_internal_test.go | 83 +++++++++++ internal/home/control.go | 2 +- internal/home/dns.go | 5 +- internal/home/web.go | 110 +++++++++++--- internal/home/web_internal_test.go | 205 ++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 25 deletions(-) diff --git a/internal/home/config.go b/internal/home/config.go index fad9b241fdf..fd01b3cf7ef 100644 --- a/internal/home/config.go +++ b/internal/home/config.go @@ -425,7 +425,7 @@ var config = &configuration{ AuthAttempts: 5, AuthBlockMin: 15, HTTPConfig: httpConfig{ - Address: netip.AddrPortFrom(netip.IPv4Unspecified(), 3000), + Address: netip.AddrPortFrom(netip.IPv6Unspecified(), 3000), SessionTTL: timeutil.Duration(30 * timeutil.Day), Pprof: &httpPprofConfig{ Enabled: false, diff --git a/internal/home/config_internal_test.go b/internal/home/config_internal_test.go index 6ab92acb785..8fc737de72f 100644 --- a/internal/home/config_internal_test.go +++ b/internal/home/config_internal_test.go @@ -1,10 +1,15 @@ package home import ( + "crypto/x509" + "net/netip" "os" "path/filepath" "testing" + "github.com/AdguardTeam/AdGuardHome/internal/aghtest" + "github.com/AdguardTeam/AdGuardHome/internal/aghtls" + "github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -89,3 +94,81 @@ func TestConfigFilePath(t *testing.T) { }) } } + +// newTestTLSConfigProvider returns a [aghtls.TLSConfigProvider] fake that +// serves the given extended TLS configuration. extTLSConf must not be nil. +func newTestTLSConfigProvider(extTLSConf *aghtls.ExtendedTLSConfig) (p *aghtest.TLSConfigProvider) { + return &aghtest.TLSConfigProvider{ + OnExtendedTLSConfig: func() (conf *aghtls.ExtendedTLSConfig) { + return extTLSConf + }, + OnRootCAs: func() (pool *x509.CertPool) { + return nil + }, + } +} + +func TestNewServerConfig_DefaultHosts(t *testing.T) { + dnsConf := &dnsConfig{ + BindHosts: nil, + Port: 53, + PendingRequests: &pendingRequests{ + Enabled: false, + }, + } + dohConf := &doHConfig{} + + conf, err := newServerConfig( + dnsConf, + &clientSourcesConfig{}, + dohConf, + newTestTLSConfigProvider(&aghtls.ExtendedTLSConfig{}), + &aghtest.Registrar{}, + nil, // clientsContainer + &aghtest.ConfigModifier{}, + ) + require.NoError(t, err) + require.Len(t, conf.UDPListenAddrs, 2) + + assert.Equal(t, netutil.IPv4Localhost().String(), conf.UDPListenAddrs[0].IP.String()) + assert.Equal(t, netutil.IPv6Localhost().String(), conf.UDPListenAddrs[1].IP.String()) +} + +func TestNewServerConfig_Issue8363BindHosts(t *testing.T) { + bindHosts := []netip.Addr{ + netip.IPv4Unspecified(), + netip.IPv6Unspecified(), + netutil.IPv4Localhost(), + netutil.IPv6Localhost(), + } + dnsConf := &dnsConfig{ + BindHosts: bindHosts, + Port: 53, + PendingRequests: &pendingRequests{ + Enabled: false, + }, + } + extTLSConf := &aghtls.ExtendedTLSConfig{ + Enabled: true, + PortDNSOverTLS: 853, + PortDNSOverQUIC: 853, + } + + conf, err := newServerConfig( + dnsConf, + &clientSourcesConfig{}, + &doHConfig{}, + newTestTLSConfigProvider(extTLSConf), + &aghtest.Registrar{}, + nil, // clientsContainer + &aghtest.ConfigModifier{}, + ) + require.NoError(t, err) + require.Len(t, conf.TLSConf.TLSListenAddrs, len(bindHosts)) + require.Len(t, conf.TLSConf.QUICListenAddrs, len(bindHosts)) + + for i, host := range bindHosts { + assert.Equal(t, host.String(), conf.TLSConf.TLSListenAddrs[i].IP.String()) + assert.Equal(t, host.String(), conf.TLSConf.QUICListenAddrs[i].IP.String()) + } +} diff --git a/internal/home/control.go b/internal/home/control.go index 40f6c62c055..d1b66f16f37 100644 --- a/internal/home/control.go +++ b/internal/home/control.go @@ -72,7 +72,7 @@ func appendDNSAddrsWithIfaces(dst []string, src []netip.Addr) (res []string, err // extTLSConf must not be nil. func collectDNSAddresses(extTLSConf *aghtls.ExtendedTLSConfig) (addrs []string, err error) { if hosts := config.DNS.BindHosts; len(hosts) == 0 { - addrs = appendDNSAddrs(addrs, netutil.IPv4Localhost()) + addrs = appendDNSAddrs(addrs, netutil.IPv4Localhost(), netutil.IPv6Localhost()) } else { addrs, err = appendDNSAddrsWithIfaces(addrs, hosts) if err != nil { diff --git a/internal/home/dns.go b/internal/home/dns.go index d2df57592b6..aeac7de1940 100644 --- a/internal/home/dns.go +++ b/internal/home/dns.go @@ -265,7 +265,10 @@ func newServerConfig( clientsContainer dnsforward.ClientsContainer, confModifier agh.ConfigModifier, ) (newConf *dnsforward.ServerConfig, err error) { - hosts := aghalg.CoalesceSlice(dnsConf.BindHosts, []netip.Addr{netutil.IPv4Localhost()}) + hosts := aghalg.CoalesceSlice(dnsConf.BindHosts, []netip.Addr{ + netutil.IPv4Localhost(), + netutil.IPv6Localhost(), + }) fwdConf := dnsConf.Config fwdConf.ClientsContainer = clientsContainer diff --git a/internal/home/web.go b/internal/home/web.go index 59a03c7d3f9..5aa77ad747d 100644 --- a/internal/home/web.go +++ b/internal/home/web.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "log/slog" + "net" "net/http" "net/netip" "runtime" @@ -334,6 +335,24 @@ func (web *webAPI) tlsConfigChanged(ctx context.Context, tlsConf *aghtls.Extende // loggerKeyServer is the key used by [webAPI] to identify servers. const loggerKeyServer = "server" +// getBindAddr returns the network and address strings to use when creating a +// listener on addr and port. network must be either "tcp" or "udp". The +// address family of addr is preserved: for the unspecified IPv4 address the +// IPv4-only network is returned, since Go's wildcard listeners otherwise +// accept connections of both address families on platforms that support +// IPv4-mapped IPv6 addresses. For the unspecified IPv6 address the returned +// address is in the ":port" form, which enables dual-stack listening. +func getBindAddr(network string, addr netip.Addr, port uint16) (listenNetwork, addrStr string) { + switch { + case !addr.IsUnspecified(): + return network, netip.AddrPortFrom(addr, port).String() + case addr.Is4(): + return network + "4", netip.AddrPortFrom(addr, port).String() + default: + return network, netutil.JoinHostPort("", port) + } +} + // start starts serving HTTP requests. func (web *webAPI) start(ctx context.Context) { defer slogutil.RecoverAndExit(ctx, web.logger, osutil.ExitCodeFailure) @@ -356,9 +375,11 @@ func (web *webAPI) start(ctx context.Context) { protocols.SetUnencryptedHTTP2(true) protocols.SetHTTP1(true) + network, addrStr := getBindAddr("tcp", web.conf.BindAddr.Addr(), web.conf.BindAddr.Port()) + // Create a new instance, because the Web is not usable after Shutdown. web.httpServer = &http.Server{ - Addr: web.conf.BindAddr.String(), + Addr: addrStr, Handler: hdlr, ReadTimeout: web.conf.ReadTimeout, ReadHeaderTimeout: web.conf.ReadHeaderTimeout, @@ -369,9 +390,16 @@ func (web *webAPI) start(ctx context.Context) { go func() { defer slogutil.RecoverAndLog(ctx, logger) - logger.InfoContext(ctx, "starting plain server", "addr", web.httpServer.Addr) + logger.InfoContext(ctx, "starting plain server", "addr", addrStr) + + ln, lErr := net.Listen(network, addrStr) + if lErr != nil { + errs <- lErr - errs <- web.httpServer.ListenAndServe() + return + } + + errs <- web.httpServer.Serve(ln) }() err := <-errs @@ -452,13 +480,13 @@ func (web *webAPI) serveTLS(ctx context.Context) (next bool) { portHTTPS = config.TLS.PortHTTPS }() - addr := netip.AddrPortFrom(web.conf.BindAddr.Addr(), portHTTPS).String() + network, addrStr := getBindAddr("tcp", web.conf.BindAddr.Addr(), portHTTPS) logger := web.baseLogger.With(loggerKeyServer, "https") hdlr := web.wrapMux(logger) web.httpsServer.server = &http.Server{ - Addr: addr, + Addr: addrStr, Handler: hdlr, TLSConfig: web.tlsConfProvider.TLSConfig(), ReadTimeout: web.conf.ReadTimeout, @@ -471,11 +499,15 @@ func (web *webAPI) serveTLS(ctx context.Context) (next bool) { printHTTPSAddresses(ctx, web.logger, extTLSConf) if web.conf.serveHTTP3 { - go web.mustStartHTTP3(ctx, addr) + go web.mustStartHTTP3(ctx, portHTTPS) } logger.InfoContext(ctx, "starting https server") - err := web.httpsServer.server.ListenAndServeTLS("", "") + ln, err := net.Listen(network, addrStr) + if err == nil { + err = web.httpsServer.server.ServeTLS(ln, "", "") + } + if !errors.Is(err, http.ErrServerClosed) { cleanupAlways(ctx, logger, web.pidFilePath) @@ -485,23 +517,26 @@ func (web *webAPI) serveTLS(ctx context.Context) (next bool) { return true } -// mustStartHTTP3 initializes and starts HTTP3 server. -func (web *webAPI) mustStartHTTP3(ctx context.Context, address string) { +// mustStartHTTP3 initializes and starts HTTP3 server on the configured bind +// address with the given port. +func (web *webAPI) mustStartHTTP3(ctx context.Context, port uint16) { defer slogutil.RecoverAndExit(ctx, web.logger, osutil.ExitCodeFailure) logger := web.baseLogger.With(loggerKeyServer, "http3") hdlr := web.wrapMux(logger) + network, addrStr := getBindAddr("udp", web.conf.BindAddr.Addr(), port) + web.httpsServer.server3 = &http3.Server{ // TODO(a.garipov): See if there is a way to use the error log as // well as timeouts here. - Addr: address, + Addr: addrStr, TLSConfig: web.tlsConfProvider.TLSConfig(), Handler: hdlr, } web.logger.DebugContext(ctx, "starting http/3 server") - err := web.httpsServer.server3.ListenAndServe() + err := serveHTTP3(ctx, logger, web.httpsServer.server3, network, addrStr) if !errors.Is(err, http.ErrServerClosed) { cleanupAlways(ctx, logger, web.pidFilePath) @@ -509,10 +544,30 @@ func (web *webAPI) mustStartHTTP3(ctx context.Context, address string) { } } -// startPprof launches the debug and profiling server on the provided port. -func startPprof(baseLogger *slog.Logger, port uint16) { - addr := netip.AddrPortFrom(netutil.IPv4Localhost(), port) +// serveHTTP3 listens for UDP packets on the given network and address, and +// serves HTTP/3 requests on srv until it is closed. The created packet +// connection is closed before returning, since [http3.Server.Serve] does not +// close connections provided by the caller. logger and srv must not be nil. +func serveHTTP3( + ctx context.Context, + logger *slog.Logger, + srv *http3.Server, + network string, + addrStr string, +) (err error) { + conn, err := net.ListenPacket(network, addrStr) + if err != nil { + // Don't wrap the error because it's informative enough as is. + return err + } + defer slogutil.CloseAndLog(ctx, logger, conn, slog.LevelDebug) + + return srv.Serve(conn) +} +// startPprof launches the debug and profiling server on the provided port on +// both IPv4 and IPv6 loopback addresses. +func startPprof(baseLogger *slog.Logger, port uint16) { runtime.SetBlockProfileRate(1) runtime.SetMutexProfileFraction(1) @@ -522,15 +577,26 @@ func startPprof(baseLogger *slog.Logger, port uint16) { ctx := context.Background() logger := baseLogger.With(slogutil.KeyPrefix, "pprof") - go func() { - defer slogutil.RecoverAndLog(ctx, logger) + go servePprof(ctx, logger, mux, netutil.IPv4Localhost(), port) + go servePprof(ctx, logger, mux, netutil.IPv6Localhost(), port) +} - logger.InfoContext(ctx, "listening", "addr", addr) - err := http.ListenAndServe(addr.String(), mux) - if !errors.Is(err, http.ErrServerClosed) { - logger.ErrorContext(ctx, "shutting down", slogutil.KeyError, err) - } - }() +// servePprof serves the pprof HTTP endpoints on the given host and port. +func servePprof( + ctx context.Context, + logger *slog.Logger, + mux *http.ServeMux, + host netip.Addr, + port uint16, +) { + defer slogutil.RecoverAndLog(ctx, logger) + + addrStr := netip.AddrPortFrom(host, port).String() + logger.InfoContext(ctx, "listening", "addr", addrStr) + err := http.ListenAndServe(addrStr, mux) + if !errors.Is(err, http.ErrServerClosed) { + logger.ErrorContext(ctx, "shutting down", slogutil.KeyError, err) + } } // handleTLSStatus is the handler for the GET /control/tls/status HTTP API. diff --git a/internal/home/web_internal_test.go b/internal/home/web_internal_test.go index e273f1092e4..bcbb67b9b48 100644 --- a/internal/home/web_internal_test.go +++ b/internal/home/web_internal_test.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/AdguardTeam/AdGuardHome/internal/agh" "github.com/AdguardTeam/AdGuardHome/internal/aghalg" @@ -22,10 +23,214 @@ import ( "github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/testutil" "github.com/AdguardTeam/golibs/timeutil" + "github.com/quic-go/quic-go/http3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestGetBindAddr(t *testing.T) { + testCases := []struct { + name string + network string + addr netip.Addr + wantNetwork string + wantAddr string + }{{ + name: "ipv4_unspecified", + network: "tcp", + addr: netip.IPv4Unspecified(), + wantNetwork: "tcp4", + wantAddr: "0.0.0.0:443", + }, { + name: "ipv6_unspecified", + network: "tcp", + addr: netip.IPv6Unspecified(), + wantNetwork: "tcp", + wantAddr: ":443", + }, { + name: "ipv4", + network: "tcp", + addr: netutil.IPv4Localhost(), + wantNetwork: "tcp", + wantAddr: "127.0.0.1:443", + }, { + name: "ipv6", + network: "tcp", + addr: netutil.IPv6Localhost(), + wantNetwork: "tcp", + wantAddr: "[::1]:443", + }, { + name: "udp_ipv4_unspecified", + network: "udp", + addr: netip.IPv4Unspecified(), + wantNetwork: "udp4", + wantAddr: "0.0.0.0:443", + }, { + name: "udp_ipv6_unspecified", + network: "udp", + addr: netip.IPv6Unspecified(), + wantNetwork: "udp", + wantAddr: ":443", + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + network, addrStr := getBindAddr(tc.network, tc.addr, 443) + + assert.Equal(t, tc.wantNetwork, network) + assert.Equal(t, tc.wantAddr, addrStr) + }) + } +} + +// canDial returns true if a connection to addr can be established using +// network. +func canDial(t *testing.T, network, addr string) (ok bool) { + t.Helper() + + conn, err := net.DialTimeout(network, addr, testTimeout) + if err != nil { + return false + } + + require.NoError(t, conn.Close()) + + return true +} + +// TestGetBindAddr_families is a regression test for the bind-scope +// compatibility of the listeners created using [getBindAddr]. It verifies on +// a real listener that the explicitly configured unspecified IPv4 address +// remains IPv4-only, while the unspecified IPv6 address enables dual-stack +// listening. +func TestGetBindAddr_families(t *testing.T) { + ln6, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("skipping: IPv6 seems unsupported: %v", err) + } + require.NoError(t, ln6.Close()) + + testCases := []struct { + name string + addr netip.Addr + wantIPv4 bool + wantIPv6 bool + }{{ + name: "ipv4_unspecified", + addr: netip.IPv4Unspecified(), + wantIPv4: true, + wantIPv6: false, + }, { + name: "ipv6_unspecified", + addr: netip.IPv6Unspecified(), + wantIPv4: true, + wantIPv6: true, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + network, addrStr := getBindAddr("tcp", tc.addr, 0) + + ln, lErr := net.Listen(network, addrStr) + require.NoError(t, lErr) + testutil.CleanupAndRequireSuccess(t, ln.Close) + + tcpAddr := testutil.RequireTypeAssert[*net.TCPAddr](t, ln.Addr()) + port := uint16(tcpAddr.Port) + + assert.Equal(t, tc.wantIPv4, canDial(t, "tcp4", netutil.JoinHostPort("127.0.0.1", port))) + assert.Equal(t, tc.wantIPv6, canDial(t, "tcp6", netutil.JoinHostPort("::1", port))) + }) + } +} + +// TestServeHTTP3_connClose is a regression test that checks that the packet +// connection owned by [serveHTTP3] is closed when the HTTP/3 server is shut +// down, since [http3.Server.Serve] does not close connections provided by the +// caller, and an unclosed connection would make rebinding the same address +// fail with EADDRINUSE, e.g. on a TLS reconfiguration. +func TestServeHTTP3_connClose(t *testing.T) { + certDER, key := newCertAndKey(t, 1) + srv := &http3.Server{ + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + }}, + MinVersion: tls.VersionTLS12, + }, + Handler: http.NewServeMux(), + } + + addrStr, served := startServeHTTP3(t, srv) + + require.NoError(t, srv.Close()) + + srvErr, _ := testutil.RequireReceive(t, served, testTimeout) + assert.ErrorIs(t, srvErr, http.ErrServerClosed) + + // The address must be available again after the server is closed. + conn, err := net.ListenPacket("udp", addrStr) + require.NoError(t, err) + require.NoError(t, conn.Close()) +} + +// startServeHTTP3 reserves a free UDP address on the IPv4 loopback, starts +// srv on it using [serveHTTP3] in a separate goroutine, and waits until the +// address is bound. Since a concurrent listener may take the reserved +// address before [serveHTTP3] binds it, the reservation is retried with a +// fresh address in that case. srv must not be nil. +func startServeHTTP3(t *testing.T, srv *http3.Server) (addrStr string, served chan error) { + t.Helper() + + const maxAttempts = 5 + + ctx := testutil.ContextWithTimeout(t, testTimeout) + + var lastErr error + for range maxAttempts { + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + + addrStr = conn.LocalAddr().String() + require.NoError(t, conn.Close()) + + served = make(chan error, 1) + go func(addr string, ch chan error) { + ch <- serveHTTP3(ctx, testLogger, srv, "udp", addr) + }(addrStr, served) + + deadline := time.Now().Add(testTimeout) + + wait: + for time.Now().Before(deadline) { + select { + case lastErr = <-served: + // The reserved address has been taken by a concurrent + // listener, so [serveHTTP3] returned early. Retry with a + // fresh address. + break wait + default: + } + + c, lErr := net.ListenPacket("udp", addrStr) + if lErr != nil { + // The address is bound by the server. + return addrStr, served + } + + require.NoError(t, c.Close()) + + time.Sleep(testTimeout / 100) + } + } + + t.Fatalf("http/3 server did not bind after %d attempts: %v", maxAttempts, lastErr) + + // Generally unreachable. + return "", nil +} + func TestWebAPI_HandleTLSConfigure(t *testing.T) { // Store the global state before making any changes. storeGlobals(t)