feat(server): opt-in IP_FREEBIND for DNS listeners (#2078)

This commit is contained in:
Dimitri Herzog
2026-06-02 12:41:45 +02:00
committed by GitHub
parent c44017aa70
commit 5d806b6a41
12 changed files with 317 additions and 23 deletions
+11 -5
View File
@@ -339,14 +339,20 @@ type Ports struct {
TLS ListenConfig `yaml:"tls"`
// URL path for DoH queries.
DOHPath string `default:"/dns-query" yaml:"dohPath"`
// Allow binding the DNS and DoT listeners to addresses that are not yet assigned to a network
// interface, via the Linux IP_FREEBIND socket option (e.g. for Tailscale/WireGuard/VRRP addresses
// brought up after startup). Has no effect on wildcard binds and is ignored, with a warning, on
// non-Linux platforms.
FreeBind bool `default:"false" yaml:"freeBind"`
}
func (c *Ports) LogConfig(logger *logrus.Entry) {
logger.Infof("DNS = %s", c.DNS)
logger.Infof("TLS = %s", c.TLS)
logger.Infof("HTTP = %s", c.HTTP)
logger.Infof("HTTPS = %s", c.HTTPS)
logger.Infof("DOHPath = %s", c.DOHPath)
logger.Infof("DNS = %s", c.DNS)
logger.Infof("TLS = %s", c.TLS)
logger.Infof("HTTP = %s", c.HTTP)
logger.Infof("HTTPS = %s", c.HTTPS)
logger.Infof("DOHPath = %s", c.DOHPath)
logger.Infof("FreeBind = %t", c.FreeBind)
}
func (c *Ports) validate() error {
+7 -5
View File
@@ -1330,11 +1330,12 @@ var _ = Describe("Ports", func() {
Describe("LogConfig", func() {
It("should log all port configurations", func() {
cfg := Ports{
DNS: ListenConfig{":53"},
HTTP: ListenConfig{":4000"},
HTTPS: ListenConfig{":443"},
TLS: ListenConfig{":853"},
DOHPath: "/dns-query",
DNS: ListenConfig{":53"},
HTTP: ListenConfig{":4000"},
HTTPS: ListenConfig{":443"},
TLS: ListenConfig{":853"},
DOHPath: "/dns-query",
FreeBind: true,
}
cfg.LogConfig(logger)
@@ -1346,6 +1347,7 @@ var _ = Describe("Ports", func() {
ContainSubstring("HTTPS"),
ContainSubstring("TLS"),
ContainSubstring("DOHPath"),
ContainSubstring("FreeBind"),
))
})
})
+9
View File
@@ -1,10 +1,19 @@
package config
import (
"github.com/creasty/defaults"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Ports.FreeBind", func() {
It("defaults to false", func() {
var ports Ports
Expect(defaults.Set(&ports)).Should(Succeed())
Expect(ports.FreeBind).Should(BeFalse())
})
})
var _ = Describe("Ports.PrivilegedPorts", func() {
It("returns privileged listen entries across DNS, HTTP, HTTPS and TLS", func() {
ports := Ports{
+5
View File
@@ -992,6 +992,11 @@
"type": "string",
"description": "URL path for DoH queries.",
"default": "/dns-query"
},
"freeBind": {
"type": "boolean",
"description": "Allow binding the DNS and DoT listeners to addresses that are not yet assigned to a network\ninterface, via the Linux IP_FREEBIND socket option (e.g. for Tailscale/WireGuard/VRRP addresses\nbrought up after startup). Has no effect on wildcard binds and is ignored, with a warning, on\nnon-Linux platforms.",
"default": false
}
},
"additionalProperties": false,
+5
View File
@@ -414,6 +414,11 @@ ports:
# optional: URL path for DoH queries.
# default: /dns-query
dohPath: /dns-query
# optional: allow binding the DNS/DoT listeners to addresses not yet assigned to an interface
# (Linux only, via IP_FREEBIND; e.g. Tailscale/WireGuard/VRRP addresses brought up after
# startup). No effect on wildcard binds; ignored with a warning on non-Linux platforms.
# default: false
freeBind: false
# optional: configuration for DOH over HTTP/3
http3:
+1
View File
@@ -55,6 +55,7 @@ All values in this section are optional.
| ports.http | One or more [IP]:Port | | Listen address for HTTP used for prometheus metrics, pprof, REST API, DoH... Example: `4000`, `:4000`, `192.168.0.1:4000`, `[4000, "[::1]:4000"]` |
| ports.https | One or more [IP]:Port | | Listen address for HTTPS used for prometheus metrics, pprof, REST API, DoH... Example: `443`, `:443`, `192.168.0.1:443`, `[443, "[::1]:443"]` |
| ports.dohPath | string | /dns-query | URL path for DoH queries. |
| ports.freeBind | bool | false | Allow binding the DNS/DoT listeners to addresses not yet assigned to an interface (Linux only, via `IP_FREEBIND`; e.g. Tailscale/WireGuard/VRRP). No effect on wildcard binds; ignored with a warning on non-Linux. |
!!! example
+40
View File
@@ -0,0 +1,40 @@
//go:build linux
// Package freebind provides a net.ListenConfig.Control hook that sets the Linux
// IP_FREEBIND (and IPV6_FREEBIND) socket option, allowing listeners to bind to
// IP addresses that are not yet assigned to a network interface.
package freebind
import (
"syscall"
"golang.org/x/sys/unix"
)
// Supported reports whether the freebind Control hook has an effect on this
// platform. It is true on Linux.
const Supported = true
// Control is a net.ListenConfig.Control function that enables binding to
// not-yet-available addresses by setting IP_FREEBIND (IPv4) and IPV6_FREEBIND
// (IPv6) on the socket.
//
// Both options are attempted; depending on the socket's address family one of
// them returns ENOPROTOOPT, which is expected. The call only fails if neither
// option could be set.
func Control(network, address string, c syscall.RawConn) error {
var sockErr error
if err := c.Control(func(fd uintptr) {
errV4 := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1)
errV6 := unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_FREEBIND, 1)
if errV4 != nil && errV6 != nil {
sockErr = errV4
}
}); err != nil {
return err
}
return sockErr
}
+49
View File
@@ -0,0 +1,49 @@
//go:build linux
package freebind_test
import (
"context"
"net"
"github.com/0xERR0R/blocky/server/freebind"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// 192.0.2.1 is in TEST-NET-1 (RFC 5737) and is never assigned to a real
// interface, so binding to it only succeeds with IP_FREEBIND.
const nonLocalAddr = "192.0.2.1:0"
var _ = Describe("freebind Control on linux", func() {
BeforeEach(func(ctx context.Context) {
// If the environment already permits non-local binds (e.g. the
// ip_nonlocal_bind sysctl is enabled), we cannot tell whether
// IP_FREEBIND is what made the bind succeed, so skip.
lc := net.ListenConfig{}
if l, err := lc.Listen(ctx, "tcp", nonLocalAddr); err == nil {
_ = l.Close()
Skip("environment permits non-local bind; cannot validate IP_FREEBIND")
}
})
It("binds a non-local TCP address", func(ctx context.Context) {
lc := net.ListenConfig{Control: freebind.Control}
l, err := lc.Listen(ctx, "tcp", nonLocalAddr)
Expect(err).ToNot(HaveOccurred())
Expect(l.Close()).To(Succeed())
})
It("binds a non-local UDP address", func(ctx context.Context) {
lc := net.ListenConfig{Control: freebind.Control}
pc, err := lc.ListenPacket(ctx, "udp", nonLocalAddr)
Expect(err).ToNot(HaveOccurred())
Expect(pc.Close()).To(Succeed())
})
It("reports the platform as supported", func() {
Expect(freebind.Supported).To(BeTrue())
})
})
+18
View File
@@ -0,0 +1,18 @@
//go:build !linux
// Package freebind provides a net.ListenConfig.Control hook that sets the Linux
// IP_FREEBIND (and IPV6_FREEBIND) socket option, allowing listeners to bind to
// IP addresses that are not yet assigned to a network interface.
//
// IP_FREEBIND is Linux-specific; on other platforms this package is a no-op.
package freebind
import "syscall"
// Supported reports whether the freebind Control hook has an effect on this
// platform. It is false on non-Linux platforms.
const Supported = false
// Control is nil on platforms without IP_FREEBIND; a nil net.ListenConfig.Control
// means the listener is created with default behaviour.
var Control func(network, address string, c syscall.RawConn) error
+13
View File
@@ -0,0 +1,13 @@
package freebind_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFreebind(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Freebind Suite")
}
+80 -13
View File
@@ -20,6 +20,7 @@ import (
"github.com/0xERR0R/blocky/model"
"github.com/0xERR0R/blocky/redis"
"github.com/0xERR0R/blocky/resolver"
"github.com/0xERR0R/blocky/server/freebind"
"github.com/0xERR0R/blocky/util"
goredis "github.com/go-redis/redis/v8"
@@ -118,7 +119,12 @@ func NewServer(ctx context.Context, cfg *config.Config) (server *Server, err err
}
}
dnsServers, err := createServers(cfg, tlsCfg)
if cfg.Ports.FreeBind && !freebind.Supported {
logger().Warn("ports.freeBind: true is only supported on Linux; " +
"ignoring on this platform (binding normally)")
}
dnsServers, err := createServers(ctx, cfg, tlsCfg)
if err != nil {
return nil, fmt.Errorf("server creation failed: %w", err)
}
@@ -213,11 +219,13 @@ func NewServer(ctx context.Context, cfg *config.Config) (server *Server, err err
return server, err
}
func createServers(cfg *config.Config, tlsCfg *tls.Config) ([]*dns.Server, error) {
func createServers(ctx context.Context, cfg *config.Config, tlsCfg *tls.Config) ([]*dns.Server, error) {
var dnsServers []*dns.Server
var err *multierror.Error
freeBind := cfg.Ports.FreeBind
addServers := func(newServer NewServerFunc, addresses config.ListenConfig) error {
for _, address := range addresses {
server, err := newServer(address)
@@ -232,10 +240,14 @@ func createServers(cfg *config.Config, tlsCfg *tls.Config) ([]*dns.Server, error
}
err = multierror.Append(err,
addServers(createUDPServer, cfg.Ports.DNS),
addServers(createTCPServer, cfg.Ports.DNS),
addServers(func(address string) (*dns.Server, error) {
return createTLSServer(address, tlsCfg)
return createUDPServer(ctx, address, freeBind)
}, cfg.Ports.DNS),
addServers(func(address string) (*dns.Server, error) {
return createTCPServer(ctx, address, freeBind)
}, cfg.Ports.DNS),
addServers(func(address string) (*dns.Server, error) {
return createTLSServer(ctx, address, tlsCfg, freeBind)
}, cfg.Ports.TLS))
if multiErr := err.ErrorOrNil(); multiErr != nil {
@@ -316,7 +328,8 @@ func newTLSListeners(
return listeners, nil
}
func createDNSServer(network, address string, tlsCfg *tls.Config) (*dns.Server, error) {
func createDNSServer(ctx context.Context, network, address string, tlsCfg *tls.Config, freeBind bool,
) (*dns.Server, error) {
srv := &dns.Server{
Addr: address,
Net: network,
@@ -334,19 +347,66 @@ func createDNSServer(network, address string, tlsCfg *tls.Config) (*dns.Server,
srv.TLSConfig = tlsCfg
}
// When freeBind is enabled (and supported), pre-create the listener with the IP_FREEBIND socket
// option and hand it to the server, which is then started via ActivateAndServe (see Server.Start).
if freeBind && freebind.Supported {
if err := attachFreeBindListener(ctx, srv, network, address, tlsCfg); err != nil {
return nil, err
}
}
return srv, nil
}
func createTLSServer(address string, tlsCfg *tls.Config) (*dns.Server, error) {
return createDNSServer("tcp-tls", address, tlsCfg)
// attachFreeBindListener creates the listener/packet connection for the given DNS server using the
// freebind Control hook and assigns it to srv.Listener / srv.PacketConn so it can be started with
// ActivateAndServe. It mirrors how miekg/dns creates listeners in ListenAndServe (including wrapping
// the DoT listener in TLS).
func attachFreeBindListener(ctx context.Context, srv *dns.Server, network, address string,
tlsCfg *tls.Config,
) error {
lc := net.ListenConfig{Control: freebind.Control}
switch network {
case "udp":
pc, err := lc.ListenPacket(ctx, "udp", address)
if err != nil {
return fmt.Errorf("freebind udp listener on %s failed: %w", address, err)
}
srv.PacketConn = pc
case "tcp":
l, err := lc.Listen(ctx, "tcp", address)
if err != nil {
return fmt.Errorf("freebind tcp listener on %s failed: %w", address, err)
}
srv.Listener = l
case "tcp-tls":
l, err := lc.Listen(ctx, "tcp", address)
if err != nil {
return fmt.Errorf("freebind tcp-tls listener on %s failed: %w", address, err)
}
srv.Listener = tls.NewListener(l, tlsCfg)
default:
return fmt.Errorf("freebind: unsupported network %q", network)
}
return nil
}
func createTCPServer(address string) (*dns.Server, error) {
return createDNSServer("tcp", address, nil)
func createTLSServer(ctx context.Context, address string, tlsCfg *tls.Config, freeBind bool,
) (*dns.Server, error) {
return createDNSServer(ctx, "tcp-tls", address, tlsCfg, freeBind)
}
func createUDPServer(address string) (*dns.Server, error) {
return createDNSServer("udp", address, nil)
func createTCPServer(ctx context.Context, address string, freeBind bool) (*dns.Server, error) {
return createDNSServer(ctx, "tcp", address, nil, freeBind)
}
func createUDPServer(ctx context.Context, address string, freeBind bool) (*dns.Server, error) {
return createDNSServer(ctx, "udp", address, nil, freeBind)
}
type redisBridgeResult struct {
@@ -506,7 +566,14 @@ func (s *Server) Start(ctx context.Context, errCh chan<- error) {
for _, srv := range s.dnsServers {
go func() {
if err := srv.ListenAndServe(); err != nil {
// When a listener/packet connection was pre-created (freeBind), serve it via
// ActivateAndServe; otherwise let miekg/dns create the socket via ListenAndServe.
serve := srv.ListenAndServe
if srv.Listener != nil || srv.PacketConn != nil {
serve = srv.ActivateAndServe
}
if err := serve(); err != nil {
errCh <- fmt.Errorf("start %s listener failed: %w", srv.Net, err)
}
}()
+79
View File
@@ -0,0 +1,79 @@
//go:build linux
package server
import (
"context"
"net"
"github.com/0xERR0R/blocky/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// 192.0.2.1 is in TEST-NET-1 (RFC 5737) and is never assigned to a real
// interface, so binding to it only succeeds with IP_FREEBIND.
const nonLocalDNSAddr = "192.0.2.1:0"
var _ = Describe("createServers with freeBind", func() {
BeforeEach(func(ctx context.Context) {
// If the environment already permits non-local binds (e.g. ip_nonlocal_bind),
// we cannot attribute success to IP_FREEBIND, so skip.
lc := net.ListenConfig{}
if l, err := lc.Listen(ctx, "tcp", nonLocalDNSAddr); err == nil {
_ = l.Close()
Skip("environment permits non-local bind; cannot validate freeBind")
}
})
When("freeBind is enabled", func() {
It("pre-binds the DNS listeners to a not-yet-available address", func(ctx context.Context) {
cfg := &config.Config{
Ports: config.Ports{
DNS: config.ListenConfig{nonLocalDNSAddr},
FreeBind: true,
},
}
servers, err := createServers(ctx, cfg, nil)
Expect(err).ToNot(HaveOccurred())
// one UDP + one TCP server for the single DNS address
Expect(servers).To(HaveLen(2))
for _, srv := range servers {
DeferCleanup(func() {
if srv.PacketConn != nil {
_ = srv.PacketConn.Close()
}
if srv.Listener != nil {
_ = srv.Listener.Close()
}
})
Expect(srv.PacketConn != nil || srv.Listener != nil).
To(BeTrue(), "expected a pre-created socket for %s", srv.Net)
}
})
})
When("freeBind is disabled", func() {
It("does not pre-create listeners (miekg/dns binds at start)", func(ctx context.Context) {
cfg := &config.Config{
Ports: config.Ports{
DNS: config.ListenConfig{nonLocalDNSAddr},
FreeBind: false,
},
}
servers, err := createServers(ctx, cfg, nil)
Expect(err).ToNot(HaveOccurred())
Expect(servers).To(HaveLen(2))
for _, srv := range servers {
Expect(srv.PacketConn).To(BeNil())
Expect(srv.Listener).To(BeNil())
}
})
})
})