detached 22211fd

fix(tcprelay): free-bind the relay under podman and rate-limit saturation warnings

commit
22211fd5af60c5e488225edc9af66f5ba51e92d9
author
brice · · 2026-10-18 20:44 UTC
parent
08776ef
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
1/2
refs
tag: v0.5.0
5 files · +114 -10
M internal/sandbox/container/container_test.go +6 -0
@@ -1020,6 +1020,12 @@ func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) {
10201020 if !newNet(nil, 0, true, "", "").DNS.FreeBind {
10211021 t.Fatal("podman needs the listener before the bridge address")
10221022 }
1023+ if newNet(nil, 0, false, "forge", "forge:80").Relay.FreeBind {
1024+ t.Fatal("Docker must keep a normal bind for the relay")
1025+ }
1026+ if !newNet(nil, 0, true, "forge", "forge:80").Relay.FreeBind {
1027+ t.Fatal("podman needs the relay listener before the bridge address")
1028+ }
10231029 }
10241030
10251031 // Guest forge access: the exception reaches the policy of every guest, and the
@@ -1020,6 +1020,12 @@ func TestOnlyPodmanGetsAFreeBindDNSListener(t *testing.T) {
1020 if !newNet(nil, 0, true, "", "").DNS.FreeBind {1020 if !newNet(nil, 0, true, "", "").DNS.FreeBind {
1021 t.Fatal("podman needs the listener before the bridge address")1021 t.Fatal("podman needs the listener before the bridge address")
1022 }1022 }
1023+ if newNet(nil, 0, false, "forge", "forge:80").Relay.FreeBind {
1024+ t.Fatal("Docker must keep a normal bind for the relay")
1025+ }
1026+ if !newNet(nil, 0, true, "forge", "forge:80").Relay.FreeBind {
1027+ t.Fatal("podman needs the relay listener before the bridge address")
1028+ }
1023 }1029 }
1024 1030
1025 // Guest forge access: the exception reaches the policy of every guest, and the1031 // Guest forge access: the exception reaches the policy of every guest, and the
M internal/sandbox/container/driver.go +3 -2
@@ -177,14 +177,15 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
177177 }
178178
179179 // newNet builds the host network plumbing. Only podman creates the bridge
180-// address late (see daemonInfo.Podman), so only there the DNS listener may come
181-// before the address; on Docker a missing gateway address stays a startup error.
180+// address late (see daemonInfo.Podman), so only there the DNS and relay
181+// listeners may come before the address; on Docker a missing gateway address stays a startup error.
182182 func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost, relay string) *netsetup.Net {
183183 n := netsetup.New(run, dnsPort, "")
184184 n.DNS.FreeBind = podman
185185 n.DNS.Override = forgeHost
186186 if relay != "" {
187187 n.Relay = tcprelay.New(relay)
188+ n.Relay.FreeBind = podman
188189 }
189190 return n
190191 }
@@ -177,14 +177,15 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
177 }177 }
178 178
179 // newNet builds the host network plumbing. Only podman creates the bridge179 // newNet builds the host network plumbing. Only podman creates the bridge
180-// address late (see daemonInfo.Podman), so only there the DNS listener may come180+// address late (see daemonInfo.Podman), so only there the DNS and relay
181-// before the address; on Docker a missing gateway address stays a startup error.181+// listeners may come before the address; on Docker a missing gateway address stays a startup error.
182 func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost, relay string) *netsetup.Net {182 func newNet(run cmdrun.Runner, dnsPort int, podman bool, forgeHost, relay string) *netsetup.Net {
183 n := netsetup.New(run, dnsPort, "")183 n := netsetup.New(run, dnsPort, "")
184 n.DNS.FreeBind = podman184 n.DNS.FreeBind = podman
185 n.DNS.Override = forgeHost185 n.DNS.Override = forgeHost
186 if relay != "" {186 if relay != "" {
187 n.Relay = tcprelay.New(relay)187 n.Relay = tcprelay.New(relay)
188+ n.Relay.FreeBind = podman
188 }189 }
189 return n190 return n
190 }191 }
M internal/sandbox/netsetup/netsetup_test.go +18 -7
@@ -5,6 +5,7 @@ package netsetup
55 import (
66 "context"
77 "errors"
8+ "net"
89 "net/netip"
910 "os"
1011 "path/filepath"
@@ -16,6 +17,17 @@ import (
1617 "bnema.dev/ermite-runner/internal/tcprelay"
1718 )
1819
20+// freePort returns a TCP port of 127.0.0.1 that was free when probed.
21+func freePort(t *testing.T) uint16 {
22+ t.Helper()
23+ ln, err := net.Listen("tcp", "127.0.0.1:0")
24+ if err != nil {
25+ t.Skip("cannot bind loopback:", err)
26+ }
27+ defer func() { _ = ln.Close() }()
28+ return uint16(ln.Addr().(*net.TCPAddr).Port)
29+}
30+
1931 func guest() netpolicy.Guest {
2032 return netpolicy.Guest{Iface: "ertap0", Gateway: netip.MustParseAddr("127.0.0.1"), Addr: netip.MustParseAddr("10.200.0.2")}
2133 }
@@ -100,14 +112,13 @@ func TestAttachStartsAndStopsTheForgeRelay(t *testing.T) {
100112 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {
101113 t.Fatal(err)
102114 }
103- // Port 0 lets the kernel pick a free port for the relay.
104- g.ForgePort = 0
105- n.Relay = tcprelay.New("127.0.0.1:1")
106- if err := n.Relay.Listen(ctx, g.Gateway, 0); err != nil {
107- t.Skip(err)
115+ // A port that was free a moment ago: Attach itself starts the relay.
116+ g.ForgePort = freePort(t)
117+ if err := n.Attach(ctx, g); err != nil {
118+ t.Fatalf("Attach with a forge port: %v", err)
108119 }
109- if _, ok := n.Relay.Addr(g.Gateway); !ok {
110- t.Fatal("relay not listening")
120+ if a, ok := n.Relay.Addr(g.Gateway); !ok || a.(*net.TCPAddr).Port != int(g.ForgePort) {
121+ t.Fatalf("relay not listening on the forge port: %v %v", a, ok)
111122 }
112123 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {
113124 t.Fatal(err)
@@ -5,6 +5,7 @@ package netsetup
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8+ "net"
8 "net/netip"9 "net/netip"
9 "os"10 "os"
10 "path/filepath"11 "path/filepath"
@@ -16,6 +17,17 @@ import (
16 "bnema.dev/ermite-runner/internal/tcprelay"17 "bnema.dev/ermite-runner/internal/tcprelay"
17 )18 )
18 19
20+// freePort returns a TCP port of 127.0.0.1 that was free when probed.
21+func freePort(t *testing.T) uint16 {
22+ t.Helper()
23+ ln, err := net.Listen("tcp", "127.0.0.1:0")
24+ if err != nil {
25+ t.Skip("cannot bind loopback:", err)
26+ }
27+ defer func() { _ = ln.Close() }()
28+ return uint16(ln.Addr().(*net.TCPAddr).Port)
29+}
30+
19 func guest() netpolicy.Guest {31 func guest() netpolicy.Guest {
20 return netpolicy.Guest{Iface: "ertap0", Gateway: netip.MustParseAddr("127.0.0.1"), Addr: netip.MustParseAddr("10.200.0.2")}32 return netpolicy.Guest{Iface: "ertap0", Gateway: netip.MustParseAddr("127.0.0.1"), Addr: netip.MustParseAddr("10.200.0.2")}
21 }33 }
@@ -100,14 +112,13 @@ func TestAttachStartsAndStopsTheForgeRelay(t *testing.T) {
100 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {112 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {
101 t.Fatal(err)113 t.Fatal(err)
102 }114 }
103- // Port 0 lets the kernel pick a free port for the relay.115+ // A port that was free a moment ago: Attach itself starts the relay.
104- g.ForgePort = 0116+ g.ForgePort = freePort(t)
105- n.Relay = tcprelay.New("127.0.0.1:1")117+ if err := n.Attach(ctx, g); err != nil {
106- if err := n.Relay.Listen(ctx, g.Gateway, 0); err != nil {118+ t.Fatalf("Attach with a forge port: %v", err)
107- t.Skip(err)
108 }119 }
109- if _, ok := n.Relay.Addr(g.Gateway); !ok {120+ if a, ok := n.Relay.Addr(g.Gateway); !ok || a.(*net.TCPAddr).Port != int(g.ForgePort) {
110- t.Fatal("relay not listening")121+ t.Fatalf("relay not listening on the forge port: %v %v", a, ok)
111 }122 }
112 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {123 if err := n.Detach(ctx, g.Iface, g.Gateway); err != nil {
113 t.Fatal(err)124 t.Fatal(err)
M internal/tcprelay/tcprelay.go +28 -1
@@ -16,9 +16,11 @@ import (
1616 "net/netip"
1717 "strconv"
1818 "sync"
19+ "syscall"
1920 "time"
2021
2122 "github.com/bnema/zerowrap"
23+ "golang.org/x/sys/unix"
2224 )
2325
2426 // MaxConns bounds the connections relayed at once for one listener: a guest
@@ -28,12 +30,20 @@ const MaxConns = 64
2830 // dialTimeout bounds the connection to the target.
2931 const dialTimeout = 10 * time.Second
3032
33+// saturationLogEvery bounds the warnings about refused connections: a guest
34+// that floods its gateway must not flood the runner's log.
35+const saturationLogEvery = 10 * time.Second
36+
3137 // Relay forwards every connection accepted on its listeners to Target.
3238 type Relay struct {
3339 // Target is host:port, resolved again for every connection.
3440 Target string
3541 // Dial connects to Target; nil means a net.Dialer.
3642 Dial func(ctx context.Context, network, addr string) (net.Conn, error)
43+ // FreeBind binds with IP_FREEBIND, so that the gateway address may be
44+ // configured after Listen (podman's netavark creates the bridge address
45+ // when the first container starts).
46+ FreeBind bool
3747
3848 mu sync.Mutex
3949 listeners map[netip.Addr]*listener
@@ -63,6 +73,15 @@ func (r *Relay) Listen(ctx context.Context, addr netip.Addr, port uint16) error
6373 return fmt.Errorf("tcprelay: already listening on %s", addr)
6474 }
6575 var lc net.ListenConfig
76+ if r.FreeBind {
77+ lc.Control = func(_, _ string, c syscall.RawConn) error {
78+ var serr error
79+ if err := c.Control(func(fd uintptr) { serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1) }); err != nil {
80+ return err
81+ }
82+ return serr
83+ }
84+ }
6685 ln, err := lc.Listen(ctx, "tcp", net.JoinHostPort(addr.String(), strconv.Itoa(int(port))))
6786 if err != nil {
6887 return fmt.Errorf("tcprelay: listen %s:%d: %w", addr, port, err)
@@ -120,6 +139,10 @@ func (l *listener) stop() {
120139
121140 func (r *Relay) serve(ctx context.Context, l *listener) {
122141 log := zerowrap.FromCtx(ctx)
142+ var (
143+ refused int
144+ lastWarn time.Time
145+ )
123146 for {
124147 c, err := l.ln.Accept()
125148 if err != nil {
@@ -132,8 +155,12 @@ func (r *Relay) serve(ctx context.Context, l *listener) {
132155 select {
133156 case l.slots <- struct{}{}:
134157 default:
135- log.Warn().Str("target", r.Target).Msg("tcprelay: too many relayed connections, refusing one")
136158 _ = c.Close()
159+ refused++
160+ if now := time.Now(); now.Sub(lastWarn) >= saturationLogEvery {
161+ log.Warn().Str("target", r.Target).Int("refused", refused).Int("max", MaxConns).Msg("tcprelay: too many relayed connections, refusing")
162+ lastWarn, refused = now, 0
163+ }
137164 continue
138165 }
139166 l.done.Add(1)
@@ -16,9 +16,11 @@ import (
16 "net/netip"16 "net/netip"
17 "strconv"17 "strconv"
18 "sync"18 "sync"
19+ "syscall"
19 "time"20 "time"
20 21
21 "github.com/bnema/zerowrap"22 "github.com/bnema/zerowrap"
23+ "golang.org/x/sys/unix"
22 )24 )
23 25
24 // MaxConns bounds the connections relayed at once for one listener: a guest26 // MaxConns bounds the connections relayed at once for one listener: a guest
@@ -28,12 +30,20 @@ const MaxConns = 64
28 // dialTimeout bounds the connection to the target.30 // dialTimeout bounds the connection to the target.
29 const dialTimeout = 10 * time.Second31 const dialTimeout = 10 * time.Second
30 32
33+// saturationLogEvery bounds the warnings about refused connections: a guest
34+// that floods its gateway must not flood the runner's log.
35+const saturationLogEvery = 10 * time.Second
36+
31 // Relay forwards every connection accepted on its listeners to Target.37 // Relay forwards every connection accepted on its listeners to Target.
32 type Relay struct {38 type Relay struct {
33 // Target is host:port, resolved again for every connection.39 // Target is host:port, resolved again for every connection.
34 Target string40 Target string
35 // Dial connects to Target; nil means a net.Dialer.41 // Dial connects to Target; nil means a net.Dialer.
36 Dial func(ctx context.Context, network, addr string) (net.Conn, error)42 Dial func(ctx context.Context, network, addr string) (net.Conn, error)
43+ // FreeBind binds with IP_FREEBIND, so that the gateway address may be
44+ // configured after Listen (podman's netavark creates the bridge address
45+ // when the first container starts).
46+ FreeBind bool
37 47
38 mu sync.Mutex48 mu sync.Mutex
39 listeners map[netip.Addr]*listener49 listeners map[netip.Addr]*listener
@@ -63,6 +73,15 @@ func (r *Relay) Listen(ctx context.Context, addr netip.Addr, port uint16) error
63 return fmt.Errorf("tcprelay: already listening on %s", addr)73 return fmt.Errorf("tcprelay: already listening on %s", addr)
64 }74 }
65 var lc net.ListenConfig75 var lc net.ListenConfig
76+ if r.FreeBind {
77+ lc.Control = func(_, _ string, c syscall.RawConn) error {
78+ var serr error
79+ if err := c.Control(func(fd uintptr) { serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_FREEBIND, 1) }); err != nil {
80+ return err
81+ }
82+ return serr
83+ }
84+ }
66 ln, err := lc.Listen(ctx, "tcp", net.JoinHostPort(addr.String(), strconv.Itoa(int(port))))85 ln, err := lc.Listen(ctx, "tcp", net.JoinHostPort(addr.String(), strconv.Itoa(int(port))))
67 if err != nil {86 if err != nil {
68 return fmt.Errorf("tcprelay: listen %s:%d: %w", addr, port, err)87 return fmt.Errorf("tcprelay: listen %s:%d: %w", addr, port, err)
@@ -120,6 +139,10 @@ func (l *listener) stop() {
120 139
121 func (r *Relay) serve(ctx context.Context, l *listener) {140 func (r *Relay) serve(ctx context.Context, l *listener) {
122 log := zerowrap.FromCtx(ctx)141 log := zerowrap.FromCtx(ctx)
142+ var (
143+ refused int
144+ lastWarn time.Time
145+ )
123 for {146 for {
124 c, err := l.ln.Accept()147 c, err := l.ln.Accept()
125 if err != nil {148 if err != nil {
@@ -132,8 +155,12 @@ func (r *Relay) serve(ctx context.Context, l *listener) {
132 select {155 select {
133 case l.slots <- struct{}{}:156 case l.slots <- struct{}{}:
134 default:157 default:
135- log.Warn().Str("target", r.Target).Msg("tcprelay: too many relayed connections, refusing one")
136 _ = c.Close()158 _ = c.Close()
159+ refused++
160+ if now := time.Now(); now.Sub(lastWarn) >= saturationLogEvery {
161+ log.Warn().Str("target", r.Target).Int("refused", refused).Int("max", MaxConns).Msg("tcprelay: too many relayed connections, refusing")
162+ lastWarn, refused = now, 0
163+ }
137 continue164 continue
138 }165 }
139 l.done.Add(1)166 l.done.Add(1)
M internal/tcprelay/tcprelay_test.go +59 -0
@@ -103,6 +103,65 @@ func TestRelayCloseStopsListenerAndConnections(t *testing.T) {
103103 r.Close(lo) // idempotent
104104 }
105105
106+// By default Listen fails on an address the host does not own; FreeBind
107+// (podman: the bridge address comes when the first container starts) binds it.
108+func TestRelayFreeBind(t *testing.T) {
109+ ip := netip.MustParseAddr("203.0.113.77") // TEST-NET-3: never configured here
110+ r := New("127.0.0.1:1")
111+ t.Cleanup(r.CloseAll)
112+ if err := r.Listen(context.Background(), ip, 0); err == nil {
113+ t.Fatal("listening on an address the host does not own must fail without FreeBind")
114+ }
115+ r.FreeBind = true
116+ if err := r.Listen(context.Background(), ip, 0); err != nil {
117+ t.Fatalf("FreeBind: %v", err)
118+ }
119+ if _, ok := r.Addr(ip); !ok {
120+ t.Fatal("no listener")
121+ }
122+}
123+
124+// At most MaxConns connections are relayed at once; the next one is closed,
125+// and a slot freed by a closed connection is reused.
126+func TestRelayConnectionLimit(t *testing.T) {
127+ r := New(echo(t))
128+ if err := r.Listen(context.Background(), lo, 0); err != nil {
129+ t.Fatal(err)
130+ }
131+ t.Cleanup(r.CloseAll)
132+ roundTrip := func(c net.Conn) error {
133+ _ = c.SetDeadline(time.Now().Add(5 * time.Second))
134+ if _, err := c.Write([]byte("x\n")); err != nil {
135+ return err
136+ }
137+ _, err := bufio.NewReader(c).ReadString('\n')
138+ return err
139+ }
140+ conns := make([]net.Conn, MaxConns)
141+ for i := range conns {
142+ conns[i] = dialRelay(t, r)
143+ if err := roundTrip(conns[i]); err != nil {
144+ t.Fatalf("connection %d: %v", i, err)
145+ }
146+ }
147+ extra := dialRelay(t, r)
148+ if err := roundTrip(extra); err == nil {
149+ t.Fatal("a connection over MaxConns was relayed")
150+ }
151+ _ = conns[0].Close()
152+ deadline := time.Now().Add(5 * time.Second)
153+ for {
154+ c := dialRelay(t, r)
155+ if roundTrip(c) == nil {
156+ break
157+ }
158+ if time.Now().After(deadline) {
159+ t.Fatal("the freed slot was not reused")
160+ }
161+ time.Sleep(20 * time.Millisecond)
162+ }
163+}
164+
106165 func TestRelayUnreachableTargetClosesTheGuestSide(t *testing.T) {
107166 ln, err := net.Listen("tcp", "127.0.0.1:0")
108167 if err != nil {
@@ -103,6 +103,65 @@ func TestRelayCloseStopsListenerAndConnections(t *testing.T) {
103 r.Close(lo) // idempotent103 r.Close(lo) // idempotent
104 }104 }
105 105
106+// By default Listen fails on an address the host does not own; FreeBind
107+// (podman: the bridge address comes when the first container starts) binds it.
108+func TestRelayFreeBind(t *testing.T) {
109+ ip := netip.MustParseAddr("203.0.113.77") // TEST-NET-3: never configured here
110+ r := New("127.0.0.1:1")
111+ t.Cleanup(r.CloseAll)
112+ if err := r.Listen(context.Background(), ip, 0); err == nil {
113+ t.Fatal("listening on an address the host does not own must fail without FreeBind")
114+ }
115+ r.FreeBind = true
116+ if err := r.Listen(context.Background(), ip, 0); err != nil {
117+ t.Fatalf("FreeBind: %v", err)
118+ }
119+ if _, ok := r.Addr(ip); !ok {
120+ t.Fatal("no listener")
121+ }
122+}
123+
124+// At most MaxConns connections are relayed at once; the next one is closed,
125+// and a slot freed by a closed connection is reused.
126+func TestRelayConnectionLimit(t *testing.T) {
127+ r := New(echo(t))
128+ if err := r.Listen(context.Background(), lo, 0); err != nil {
129+ t.Fatal(err)
130+ }
131+ t.Cleanup(r.CloseAll)
132+ roundTrip := func(c net.Conn) error {
133+ _ = c.SetDeadline(time.Now().Add(5 * time.Second))
134+ if _, err := c.Write([]byte("x\n")); err != nil {
135+ return err
136+ }
137+ _, err := bufio.NewReader(c).ReadString('\n')
138+ return err
139+ }
140+ conns := make([]net.Conn, MaxConns)
141+ for i := range conns {
142+ conns[i] = dialRelay(t, r)
143+ if err := roundTrip(conns[i]); err != nil {
144+ t.Fatalf("connection %d: %v", i, err)
145+ }
146+ }
147+ extra := dialRelay(t, r)
148+ if err := roundTrip(extra); err == nil {
149+ t.Fatal("a connection over MaxConns was relayed")
150+ }
151+ _ = conns[0].Close()
152+ deadline := time.Now().Add(5 * time.Second)
153+ for {
154+ c := dialRelay(t, r)
155+ if roundTrip(c) == nil {
156+ break
157+ }
158+ if time.Now().After(deadline) {
159+ t.Fatal("the freed slot was not reused")
160+ }
161+ time.Sleep(20 * time.Millisecond)
162+ }
163+}
164+
106 func TestRelayUnreachableTargetClosesTheGuestSide(t *testing.T) {165 func TestRelayUnreachableTargetClosesTheGuestSide(t *testing.T) {
107 ln, err := net.Listen("tcp", "127.0.0.1:0")166 ln, err := net.Listen("tcp", "127.0.0.1:0")
108 if err != nil {167 if err != nil {

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