fix(dnsfwd): IP_FREEBIND only for a podman daemon
The free bind was unconditional, so on Docker and in the VM driver a missing gateway address would no longer fail fast. dnsfwd.Forwarder gets a FreeBind option (off by default); the container driver turns it on only when the daemon's version components say Podman. Tests: default fails on a missing address, FreeBind binds it, the driver picks per daemon.
- commit
- 4f13c7a9fce3685accad95353d9bf9f1a86b8741
- author
- brice · · 2026-10-12 22:01 UTC
- parent
- 7ddb5da
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +65 -15
M internal/dnsfwd/dnsfwd.go +16 -9
| @@ -57,6 +57,11 @@ type Forwarder struct { | |||
| 57 | 57 | port int | |
| 58 | 58 | // Lifetime overrides TCPLifetime; set before Listen (tests). | |
| 59 | 59 | Lifetime time.Duration | |
| 60 | + | // FreeBind binds with IP_FREEBIND, so that the gateway address may be | |
| 61 | + | // configured after the listener. Off by default: with Docker the address | |
| 62 | + | // exists when the network is created and a missing one is an error to | |
| 63 | + | // report at once, not a listener on an address nobody owns. Set before Listen. | |
| 64 | + | FreeBind bool | |
| 60 | 65 | ||
| 61 | 66 | mu sync.Mutex | |
| 62 | 67 | listeners map[netip.Addr]*listener | |
| @@ -95,16 +100,18 @@ func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error { | |||
| 95 | 100 | return fmt.Errorf("dnsfwd: already listening on %s", addr) | |
| 96 | 101 | } | |
| 97 | 102 | hp := net.JoinHostPort(addr.String(), strconv.Itoa(f.port)) | |
| 98 | - | // IP_FREEBIND: the gateway address may not exist yet. Docker creates the | |
| 99 | - | // bridge (and its address) with the network, but podman's netavark only when | |
| 100 | - | // the first container starts, which is after the policy and this listener. | |
| 101 | - | lc := net.ListenConfig{Control: func(_, _ string, c syscall.RawConn) error { | |
| 102 | - | var serr error | |
| 103 | - | if err := c.Control(func(fd uintptr) { serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1) }); err != nil { | |
| 104 | - | return err | |
| 103 | + | var lc net.ListenConfig | |
| 104 | + | if f.FreeBind { | |
| 105 | + | // The gateway address may not exist yet (podman's netavark configures the | |
| 106 | + | // bridge address when the first container starts, after this listener). | |
| 107 | + | lc.Control = func(_, _ string, c syscall.RawConn) error { | |
| 108 | + | var serr error | |
| 109 | + | if err := c.Control(func(fd uintptr) { serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1) }); err != nil { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | return serr | |
| 105 | 113 | } | |
| 106 | - | return serr | |
| 107 | - | }} | |
| 114 | + | } | |
| 108 | 115 | pc, err := lc.ListenPacket(ctx, "udp", hp) | |
| 109 | 116 | if err != nil { | |
| 110 | 117 | return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err) | |
| @@ -57,6 +57,11 @@ type Forwarder struct { | |||||
| 57 | port int | 57 | port int | ||
| 58 | // Lifetime overrides TCPLifetime; set before Listen (tests). | 58 | // Lifetime overrides TCPLifetime; set before Listen (tests). | ||
| 59 | Lifetime time.Duration | 59 | Lifetime time.Duration | ||
| 60 | + | // FreeBind binds with IP_FREEBIND, so that the gateway address may be | |||
| 61 | + | // configured after the listener. Off by default: with Docker the address | |||
| 62 | + | // exists when the network is created and a missing one is an error to | |||
| 63 | + | // report at once, not a listener on an address nobody owns. Set before Listen. | |||
| 64 | + | FreeBind bool | |||
| 60 | 65 | ||||
| 61 | mu sync.Mutex | 66 | mu sync.Mutex | ||
| 62 | listeners map[netip.Addr]*listener | 67 | listeners map[netip.Addr]*listener | ||
| @@ -95,16 +100,18 @@ func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error { | |||||
| 95 | return fmt.Errorf("dnsfwd: already listening on %s", addr) | 100 | return fmt.Errorf("dnsfwd: already listening on %s", addr) | ||
| 96 | } | 101 | } | ||
| 97 | hp := net.JoinHostPort(addr.String(), strconv.Itoa(f.port)) | 102 | hp := net.JoinHostPort(addr.String(), strconv.Itoa(f.port)) | ||
| 98 | - | // IP_FREEBIND: the gateway address may not exist yet. Docker creates the | 103 | + | var lc net.ListenConfig |
| 99 | - | // bridge (and its address) with the network, but podman's netavark only when | 104 | + | if f.FreeBind { |
| 100 | - | // the first container starts, which is after the policy and this listener. | 105 | + | // The gateway address may not exist yet (podman's netavark configures the |
| 101 | - | lc := net.ListenConfig{Control: func(_, _ string, c syscall.RawConn) error { | 106 | + | // bridge address when the first container starts, after this listener). |
| 102 | - | var serr error | 107 | + | lc.Control = func(_, _ string, c syscall.RawConn) error { |
| 103 | - | if err := c.Control(func(fd uintptr) { serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1) }); err != nil { | 108 | + | var serr error |
| 104 | - | return err | 109 | + | if err := c.Control(func(fd uintptr) { serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1) }); err != nil { |
| 110 | + | return err | |||
| 111 | + | } | |||
| 112 | + | return serr | |||
| 105 | } | 113 | } | ||
| 106 | - | return serr | 114 | + | } |
| 107 | - | }} | |||
| 108 | pc, err := lc.ListenPacket(ctx, "udp", hp) | 115 | pc, err := lc.ListenPacket(ctx, "udp", hp) | ||
| 109 | if err != nil { | 116 | if err != nil { | ||
| 110 | return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err) | 117 | return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err) | ||
M internal/dnsfwd/dnsfwd_test.go +16 -5
| @@ -441,15 +441,26 @@ func TestUDPSaturationDropsAndIsolatesListeners(t *testing.T) { | |||
| 441 | 441 | } | |
| 442 | 442 | } | |
| 443 | 443 | ||
| 444 | - | // The gateway address can be configured after the listener starts (podman's | |
| 445 | - | // netavark creates the bridge address only when the first container starts): | |
| 446 | - | // Listen must bind an address the host does not own yet (IP_FREEBIND). | |
| 447 | - | func TestListenOnAnAddressTheHostDoesNotOwnYet(t *testing.T) { | |
| 444 | + | // By default Listen fails fast on an address the host does not own (Docker and | |
| 445 | + | // the VM driver have configured it by then: a missing one is an error). | |
| 446 | + | func TestListenOnAMissingAddressFailsByDefault(t *testing.T) { | |
| 448 | 447 | f := New(func() []string { return nil }, 0) | |
| 449 | 448 | t.Cleanup(f.CloseAll) | |
| 450 | 449 | ip := netip.MustParseAddr("203.0.113.77") // TEST-NET-3: never configured here | |
| 450 | + | if err := f.Listen(context.Background(), ip); err == nil { | |
| 451 | + | t.Fatal("listening on an address the host does not own must fail without FreeBind") | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | // With FreeBind (podman's netavark creates the bridge address only when the | |
| 456 | + | // first container starts) Listen binds an address the host does not own yet. | |
| 457 | + | func TestFreeBindListensOnAnAddressTheHostDoesNotOwnYet(t *testing.T) { | |
| 458 | + | f := New(func() []string { return nil }, 0) | |
| 459 | + | f.FreeBind = true | |
| 460 | + | t.Cleanup(f.CloseAll) | |
| 461 | + | ip := netip.MustParseAddr("203.0.113.77") | |
| 451 | 462 | if err := f.Listen(context.Background(), ip); err != nil { | |
| 452 | - | t.Fatalf("listen on a not-yet-configured address: %v", err) | |
| 463 | + | t.Fatalf("FreeBind: %v", err) | |
| 453 | 464 | } | |
| 454 | 465 | if _, ok := f.Addr(ip); !ok { | |
| 455 | 466 | t.Fatal("no listener") | |
| @@ -441,15 +441,26 @@ func TestUDPSaturationDropsAndIsolatesListeners(t *testing.T) { | |||||
| 441 | } | 441 | } | ||
| 442 | } | 442 | } | ||
| 443 | 443 | ||||
| 444 | - | // The gateway address can be configured after the listener starts (podman's | 444 | + | // By default Listen fails fast on an address the host does not own (Docker and |
| 445 | - | // netavark creates the bridge address only when the first container starts): | 445 | + | // the VM driver have configured it by then: a missing one is an error). |
| 446 | - | // Listen must bind an address the host does not own yet (IP_FREEBIND). | 446 | + | func TestListenOnAMissingAddressFailsByDefault(t *testing.T) { |
| 447 | - | func TestListenOnAnAddressTheHostDoesNotOwnYet(t *testing.T) { | |||
| 448 | f := New(func() []string { return nil }, 0) | 447 | f := New(func() []string { return nil }, 0) | ||
| 449 | t.Cleanup(f.CloseAll) | 448 | t.Cleanup(f.CloseAll) | ||
| 450 | ip := netip.MustParseAddr("203.0.113.77") // TEST-NET-3: never configured here | 449 | ip := netip.MustParseAddr("203.0.113.77") // TEST-NET-3: never configured here | ||
| 450 | + | if err := f.Listen(context.Background(), ip); err == nil { | |||
| 451 | + | t.Fatal("listening on an address the host does not own must fail without FreeBind") | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // With FreeBind (podman's netavark creates the bridge address only when the | |||
| 456 | + | // first container starts) Listen binds an address the host does not own yet. | |||
| 457 | + | func TestFreeBindListensOnAnAddressTheHostDoesNotOwnYet(t *testing.T) { | |||
| 458 | + | f := New(func() []string { return nil }, 0) | |||
| 459 | + | f.FreeBind = true | |||
| 460 | + | t.Cleanup(f.CloseAll) | |||
| 461 | + | ip := netip.MustParseAddr("203.0.113.77") | |||
| 451 | if err := f.Listen(context.Background(), ip); err != nil { | 462 | if err := f.Listen(context.Background(), ip); err != nil { | ||
| 452 | - | t.Fatalf("listen on a not-yet-configured address: %v", err) | 463 | + | t.Fatalf("FreeBind: %v", err) |
| 453 | } | 464 | } | ||
| 454 | if _, ok := f.Addr(ip); !ok { | 465 | if _, ok := f.Addr(ip); !ok { | ||
| 455 | t.Fatal("no listener") | 466 | t.Fatal("no listener") | ||
M internal/sandbox/container/container_test.go +11 -0
| @@ -1010,3 +1010,14 @@ func TestSocketPathTooLongIsExplained(t *testing.T) { | |||
| 1010 | 1010 | t.Fatalf("leftovers: %v", left) | |
| 1011 | 1011 | } | |
| 1012 | 1012 | } | |
| 1013 | + | ||
| 1014 | + | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | |
| 1015 | + | // address; Docker keeps a normal bind. | |
| 1016 | + | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | |
| 1017 | + | if newNet(nil, 0, false).DNS.FreeBind { | |
| 1018 | + | t.Fatal("Docker must keep a normal bind") | |
| 1019 | + | } | |
| 1020 | + | if !newNet(nil, 0, true).DNS.FreeBind { | |
| 1021 | + | t.Fatal("podman needs the listener before the bridge address") | |
| 1022 | + | } | |
| 1023 | + | } | |
| @@ -1010,3 +1010,14 @@ func TestSocketPathTooLongIsExplained(t *testing.T) { | |||||
| 1010 | t.Fatalf("leftovers: %v", left) | 1010 | t.Fatalf("leftovers: %v", left) | ||
| 1011 | } | 1011 | } | ||
| 1012 | } | 1012 | } | ||
| 1013 | + | ||||
| 1014 | + | // GO-002: only a podman daemon gets a DNS listener that may precede the gateway | |||
| 1015 | + | // address; Docker keeps a normal bind. | |||
| 1016 | + | func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) { | |||
| 1017 | + | if newNet(nil, 0, false).DNS.FreeBind { | |||
| 1018 | + | t.Fatal("Docker must keep a normal bind") | |||
| 1019 | + | } | |||
| 1020 | + | if !newNet(nil, 0, true).DNS.FreeBind { | |||
| 1021 | + | t.Fatal("podman needs the listener before the bridge address") | |||
| 1022 | + | } | |||
| 1023 | + | } | |||
M internal/sandbox/container/driver.go +10 -1
| @@ -163,7 +163,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 163 | 163 | } | |
| 164 | 164 | d.net = opt.Net | |
| 165 | 165 | if d.net == nil { | |
| 166 | - | d.net = netsetup.New(opt.Run, cfg.DNSPort, "") | |
| 166 | + | d.net = newNet(opt.Run, cfg.DNSPort, info.Podman) | |
| 167 | 167 | } | |
| 168 | 168 | if err := d.net.EnableForwarding(); err != nil { | |
| 169 | 169 | return fail(err) | |
| @@ -171,6 +171,15 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 171 | 171 | return d, nil | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | // newNet builds the host network plumbing. Only podman creates the bridge | |
| 175 | + | // address late (see daemonInfo.Podman), so only there the DNS listener may come | |
| 176 | + | // before the address; on Docker a missing gateway address stays a startup error. | |
| 177 | + | func newNet(run cmdrun.Runner, dnsPort int, podman bool) *netsetup.Net { | |
| 178 | + | n := netsetup.New(run, dnsPort, "") | |
| 179 | + | n.DNS.FreeBind = podman | |
| 180 | + | return n | |
| 181 | + | } | |
| 182 | + | ||
| 174 | 183 | // maxSockPath is the longest Unix socket path Linux accepts (108 with the NUL). | |
| 175 | 184 | const maxSockPath = 107 | |
| 176 | 185 | ||
| @@ -163,7 +163,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 163 | } | 163 | } | ||
| 164 | d.net = opt.Net | 164 | d.net = opt.Net | ||
| 165 | if d.net == nil { | 165 | if d.net == nil { | ||
| 166 | - | d.net = netsetup.New(opt.Run, cfg.DNSPort, "") | 166 | + | d.net = newNet(opt.Run, cfg.DNSPort, info.Podman) |
| 167 | } | 167 | } | ||
| 168 | if err := d.net.EnableForwarding(); err != nil { | 168 | if err := d.net.EnableForwarding(); err != nil { | ||
| 169 | return fail(err) | 169 | return fail(err) | ||
| @@ -171,6 +171,15 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||||
| 171 | return d, nil | 171 | return d, nil | ||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | + | // newNet builds the host network plumbing. Only podman creates the bridge | |||
| 175 | + | // address late (see daemonInfo.Podman), so only there the DNS listener may come | |||
| 176 | + | // before the address; on Docker a missing gateway address stays a startup error. | |||
| 177 | + | func newNet(run cmdrun.Runner, dnsPort int, podman bool) *netsetup.Net { | |||
| 178 | + | n := netsetup.New(run, dnsPort, "") | |||
| 179 | + | n.DNS.FreeBind = podman | |||
| 180 | + | return n | |||
| 181 | + | } | |||
| 182 | + | ||||
| 174 | // maxSockPath is the longest Unix socket path Linux accepts (108 with the NUL). | 183 | // maxSockPath is the longest Unix socket path Linux accepts (108 with the NUL). | ||
| 175 | const maxSockPath = 107 | 184 | const maxSockPath = 107 | ||
| 176 | 185 | ||||
M internal/sandbox/container/engine.go +12 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "net/netip" | |
| 11 | + | "strings" | |
| 11 | 12 | ||
| 12 | 13 | cerrdefs "github.com/containerd/errdefs" | |
| 13 | 14 | "github.com/moby/moby/api/types/container" | |
| @@ -47,6 +48,9 @@ type daemonInfo struct { | |||
| 47 | 48 | Rootless bool | |
| 48 | 49 | Runtimes []string | |
| 49 | 50 | OSType string | |
| 51 | + | // Podman: the "Docker API" is podman's compatibility service, whose netavark | |
| 52 | + | // configures a bridge address only when the first container starts. | |
| 53 | + | Podman bool | |
| 50 | 54 | } | |
| 51 | 55 | ||
| 52 | 56 | type networkSpec struct { | |
| @@ -108,6 +112,14 @@ func (e *dockerEngine) Info(ctx context.Context) (daemonInfo, error) { | |||
| 108 | 112 | for name := range r.Info.Runtimes { | |
| 109 | 113 | di.Runtimes = append(di.Runtimes, name) | |
| 110 | 114 | } | |
| 115 | + | // Best effort: a daemon that cannot say what it is is treated as Docker. | |
| 116 | + | if v, err := e.cli.ServerVersion(ctx, client.ServerVersionOptions{}); err == nil { | |
| 117 | + | for _, c := range v.Components { | |
| 118 | + | if strings.HasPrefix(c.Name, "Podman") { | |
| 119 | + | di.Podman = true | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 111 | 123 | return di, nil | |
| 112 | 124 | } | |
| 113 | 125 | ||
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "net/netip" | 10 | "net/netip" | ||
| 11 | + | "strings" | |||
| 11 | 12 | ||||
| 12 | cerrdefs "github.com/containerd/errdefs" | 13 | cerrdefs "github.com/containerd/errdefs" | ||
| 13 | "github.com/moby/moby/api/types/container" | 14 | "github.com/moby/moby/api/types/container" | ||
| @@ -47,6 +48,9 @@ type daemonInfo struct { | |||||
| 47 | Rootless bool | 48 | Rootless bool | ||
| 48 | Runtimes []string | 49 | Runtimes []string | ||
| 49 | OSType string | 50 | OSType string | ||
| 51 | + | // Podman: the "Docker API" is podman's compatibility service, whose netavark | |||
| 52 | + | // configures a bridge address only when the first container starts. | |||
| 53 | + | Podman bool | |||
| 50 | } | 54 | } | ||
| 51 | 55 | ||||
| 52 | type networkSpec struct { | 56 | type networkSpec struct { | ||
| @@ -108,6 +112,14 @@ func (e *dockerEngine) Info(ctx context.Context) (daemonInfo, error) { | |||||
| 108 | for name := range r.Info.Runtimes { | 112 | for name := range r.Info.Runtimes { | ||
| 109 | di.Runtimes = append(di.Runtimes, name) | 113 | di.Runtimes = append(di.Runtimes, name) | ||
| 110 | } | 114 | } | ||
| 115 | + | // Best effort: a daemon that cannot say what it is is treated as Docker. | |||
| 116 | + | if v, err := e.cli.ServerVersion(ctx, client.ServerVersionOptions{}); err == nil { | |||
| 117 | + | for _, c := range v.Components { | |||
| 118 | + | if strings.HasPrefix(c.Name, "Podman") { | |||
| 119 | + | di.Podman = true | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 111 | return di, nil | 123 | return di, nil | ||
| 112 | } | 124 | } | ||
| 113 | 125 | ||||