mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-12 02:01:13 +00:00
feat(server): opt-in IP_FREEBIND for DNS listeners (#2078)
This commit is contained in:
+11
-5
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user