detached 4f13c7a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5757 port int
5858 // Lifetime overrides TCPLifetime; set before Listen (tests).
5959 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
6065
6166 mu sync.Mutex
6267 listeners map[netip.Addr]*listener
@@ -95,16 +100,18 @@ func (f *Forwarder) Listen(ctx context.Context, addr netip.Addr) error {
95100 return fmt.Errorf("dnsfwd: already listening on %s", addr)
96101 }
97102 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
105113 }
106- return serr
107- }}
114+ }
108115 pc, err := lc.ListenPacket(ctx, "udp", hp)
109116 if err != nil {
110117 return fmt.Errorf("dnsfwd: listen udp %s: %w", hp, err)
@@ -57,6 +57,11 @@ type Forwarder struct {
57 port int57 port int
58 // Lifetime overrides TCPLifetime; set before Listen (tests).58 // Lifetime overrides TCPLifetime; set before Listen (tests).
59 Lifetime time.Duration59 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.Mutex66 mu sync.Mutex
62 listeners map[netip.Addr]*listener67 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 the103+ var lc net.ListenConfig
99- // bridge (and its address) with the network, but podman's netavark only when104+ 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 error107+ 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 err109+ 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 serr114+ }
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) {
441441 }
442442 }
443443
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) {
448447 f := New(func() []string { return nil }, 0)
449448 t.Cleanup(f.CloseAll)
450449 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")
451462 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)
453464 }
454465 if _, ok := f.Addr(ip); !ok {
455466 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's444+// 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 here449 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) {
10101010 t.Fatalf("leftovers: %v", left)
10111011 }
10121012 }
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)
163163 }
164164 d.net = opt.Net
165165 if d.net == nil {
166- d.net = netsetup.New(opt.Run, cfg.DNSPort, "")
166+ d.net = newNet(opt.Run, cfg.DNSPort, info.Podman)
167167 }
168168 if err := d.net.EnableForwarding(); err != nil {
169169 return fail(err)
@@ -171,6 +171,15 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
171171 return d, nil
172172 }
173173
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+
174183 // maxSockPath is the longest Unix socket path Linux accepts (108 with the NUL).
175184 const maxSockPath = 107
176185
@@ -163,7 +163,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
163 }163 }
164 d.net = opt.Net164 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, nil171 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 = 107184 const maxSockPath = 107
176 185
M internal/sandbox/container/engine.go +12 -0
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "net/netip"
11+ "strings"
1112
1213 cerrdefs "github.com/containerd/errdefs"
1314 "github.com/moby/moby/api/types/container"
@@ -47,6 +48,9 @@ type daemonInfo struct {
4748 Rootless bool
4849 Runtimes []string
4950 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
5054 }
5155
5256 type networkSpec struct {
@@ -108,6 +112,14 @@ func (e *dockerEngine) Info(ctx context.Context) (daemonInfo, error) {
108112 for name := range r.Info.Runtimes {
109113 di.Runtimes = append(di.Runtimes, name)
110114 }
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+ }
111123 return di, nil
112124 }
113125
@@ -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 bool48 Rootless bool
48 Runtimes []string49 Runtimes []string
49 OSType string50 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, nil123 return di, nil
112 }124 }
113 125

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close