Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,6 @@ linuxkit
Linuxy
loadbalancer
loblaw
localnet
logdna
loglines
logz
Expand Down Expand Up @@ -415,6 +414,7 @@ NLMSG
nlmsghdr
noarch
nocopy
noctx
nologin
nologo
nomount
Expand Down Expand Up @@ -482,8 +482,8 @@ podmonitor
podsecuritypolicytemplate
POLLIN
POptions
portbinding
portforward
portfwd
portmap
portmapping
portproxy
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@
"sign": "node scripts/ts-wrapper.js scripts/sign.ts",
"wix": "node scripts/ts-wrapper.js scripts/wix.ts",
"test": "yarn lint:nofix && yarn test:unit && yarn test:extra",
"test:unit": "yarn test:unit:jest && yarn test:unit:nerdctl-stub && yarn test:unit:wsl-helper && yarn test:unit:rdctl",
"test:unit": "yarn test:unit:jest && yarn test:unit:nerdctl-stub && yarn test:unit:wsl-helper && yarn test:unit:rdctl && yarn test:unit:guestagent",
"test:unit:jest": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:unit:watch": "yarn test:unit -- --watch",
"test:unit:nerdctl-stub": "cd ./src/go/nerdctl-stub/ && go test ./...",
"test:unit:rdctl": "cd ./src/go/rdctl/ && go test ./...",
"test:unit:wsl-helper": "cd ./src/go/wsl-helper/ && go generate ./... && go test ./...",
"test:unit:guestagent": "cd ./src/go/guestagent/ && go test ./...",
"test:extra": "yarn test:extra:api-schema",
"test:extra:api-schema": "node scripts/ts-wrapper.js scripts/check-api-schema.ts",
"test:e2e": "node scripts/ts-wrapper.js scripts/e2e.ts",
Expand Down
4 changes: 4 additions & 0 deletions src/go/guestagent/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ require (
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
Expand Down Expand Up @@ -94,6 +96,7 @@ require (
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/term v0.43.0 // indirect
Expand All @@ -106,6 +109,7 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
Expand Down
10 changes: 10 additions & 0 deletions src/go/guestagent/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA=
github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok=
github.com/armon/go-proxyproto v0.0.0-20210323213023-7e956b284f0a/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
Expand Down Expand Up @@ -116,6 +118,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
Expand All @@ -134,6 +138,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048 h1:jaqViOFFlZtkAwqvwZN+id37fosQqR5l3Oki9Dk4hz8=
github.com/inetaf/tcpproxy v0.0.0-20250222171855-c4b9df066048/go.mod h1:Di7LXRyUcnvAcLicFhtM9/MlZl/TNgRSDHORM2c6CMI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
Expand Down Expand Up @@ -274,6 +280,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
Expand Down Expand Up @@ -396,6 +404,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f h1:O2w2DymsOlM/nv2pLNWCMCYOldgBBMkD7H0/prN5W2k=
gvisor.dev/gvisor v0.0.0-20240916094835-a174eb65023f/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
Expand Down
7 changes: 6 additions & 1 deletion src/go/guestagent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ func runAgent(
adminInstall bool,
k8sAPIPort, tapIfaceIP string,
) error {
bindIP := net.ParseIP(tapIfaceIP)
if bindIP == nil {
return fmt.Errorf("invalid tap interface IP %q", tapIfaceIP)
}

groupCtx, cancel := context.WithCancel(context.Background())
defer cancel()
group, ctx := errgroup.WithContext(groupCtx)
Expand Down Expand Up @@ -238,7 +243,7 @@ func runAgent(
}

group.Go(func() error {
procScanner, err := procnet.NewProcNetScanner(ctx, portTracker, procNetScanInterval)
procScanner, err := procnet.NewProcNetScanner(ctx, portTracker, bindIP, procNetScanInterval)
if err != nil {
return fmt.Errorf("scanning /proc/net/{tcp, udp} failed: %w", err)
}
Expand Down
252 changes: 252 additions & 0 deletions src/go/guestagent/pkg/procnet/loopback_forwarder_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/*
Copyright © 2026 SUSE LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
*/

// loopbackForwarder runs a userspace TCP/UDP proxy inside the container
// engine's network namespace. For each 127.0.0.1 listener procnet
// observes (--network=host containers), it opens a matching listener on
// bindIP -- the tap-interface IP that gvisor-tap-vsock host-switch
// already routes to -- and pipes accepted connections to
// 127.0.0.1:<port>.
//
// This replaces the PREROUTING DNAT rule procnet previously wrote into
// the nat table. Both paths bridge eth0-arriving traffic to the
// engine-internal loopback, but userspace forwarding lives outside the
// nat table, so it cannot collide with CNI-HOSTPORT-DNAT or DOCKER.
// Userspace forwarding removes the engine-chain probing surface that
// #10280 added.
//
// TCP mirrors Lima's pkg/portfwd/listener.go. UDP delegates to
// gvisor-tap-vsock's forwarder.UDPProxy, the same code Lima uses for
// its UDP path in pkg/portfwd/client.go.

package procnet

import (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"sync"
"time"

"github.com/Masterminds/log-go"
"github.com/containers/gvisor-tap-vsock/pkg/services/forwarder"
)

const (
protoTCP = "tcp"
protoUDP = "udp"
)

type loopbackForwarder struct {
bindIP net.IP
dialer net.Dialer

mu sync.Mutex
tcp map[string]net.Listener
udp map[string]*forwarder.UDPProxy
}

func newLoopbackForwarder(bindIP net.IP) *loopbackForwarder {
return &loopbackForwarder{
bindIP: bindIP,
tcp: make(map[string]net.Listener),
udp: make(map[string]*forwarder.UDPProxy),
}
}

func key(proto string, port uint16) string {
return proto + "/" + strconv.Itoa(int(port))
}

// Add opens a userspace forwarder for proto/port. Repeated Adds for
// the same key are idempotent. The caller must call Remove when the
// upstream listener disappears.
//
// EADDRINUSE on the bind step propagates as a plain listen error.
// The scanner's publish path rolls back the tracker entry and retries
// each tick; the per-port log-once flag bounds the noise on a
// persistent collision. The mixed-binding case (a host-network
// container holding both 127.0.0.1:port and 0.0.0.0:port) no longer
// reaches this path: the scanner skips Add when the bindings include
// a wildcard entry, since the wildcard listener already accepts
// bindIP:port directly. The remaining EADDRINUSE trigger is an
// unrelated process inside the engine namespace holding bindIP:port.
func (f *loopbackForwarder) Add(ctx context.Context, proto string, port uint16) error {
k := key(proto, port)
f.mu.Lock()
defer f.mu.Unlock()

switch proto {
case protoTCP:
if _, ok := f.tcp[k]; ok {
return nil
}
lis, err := net.ListenTCP(protoTCP, &net.TCPAddr{IP: f.bindIP, Port: int(port)})
if err != nil {
return fmt.Errorf("listen %s: %w", k, err)
}
f.tcp[k] = lis
go f.acceptTCP(ctx, lis, port)
case protoUDP:
if _, ok := f.udp[k]; ok {
return nil
}
pc, err := net.ListenUDP(protoUDP, &net.UDPAddr{IP: f.bindIP, Port: int(port)})
if err != nil {
return fmt.Errorf("listen %s: %w", k, err)
}
target := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port)))
// Each flow's idle timeout is forwarder.UDPConnTrackTimeout (90s).
// The dial closure runs for every new client flow, including
// flows that arrive long after Add returns. ctx must therefore
// be a forwarder-lifetime context (in production, the
// scanner's lifetime context); a request-scoped or per-tick
// ctx would silently break new-flow dialing once cancelled.
proxy, err := forwarder.NewUDPProxy(pc, func() (net.Conn, error) {
return f.dialer.DialContext(ctx, protoUDP, target)
})
if err != nil {
_ = pc.Close()
return fmt.Errorf("udp proxy %s: %w", k, err)
}
f.udp[k] = proxy
go proxy.Run()
default:
return fmt.Errorf("loopback forwarder: unsupported protocol %q", proto)
}
return nil
}

func (f *loopbackForwarder) Remove(proto string, port uint16) error {
k := key(proto, port)
f.mu.Lock()
defer f.mu.Unlock()
switch proto {
case protoTCP:
if lis, ok := f.tcp[k]; ok {
delete(f.tcp, k)
return lis.Close()
}
case protoUDP:
if proxy, ok := f.udp[k]; ok {
delete(f.udp, k)
return proxy.Close()
}
}
return nil
}

// Close shuts every registered TCP listener and UDP proxy. In-flight
// pipeTCP goroutines for accepted connections continue until the
// peer disconnects or their half-close drain deadline (30s) fires;
// Close does not wait for them. This is intentional for
// process-exit shutdown — the goroutines die with the process.
func (f *loopbackForwarder) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
var errs []error
for k, l := range f.tcp {
if err := l.Close(); err != nil {
errs = append(errs, err)
}
delete(f.tcp, k)
}
for k, proxy := range f.udp {
if err := proxy.Close(); err != nil {
errs = append(errs, err)
}
delete(f.udp, k)
}
return errors.Join(errs...)
}

const (
acceptRetryInitialBackoff = 100 * time.Millisecond
acceptRetryMaxBackoff = 5 * time.Second
halfCloseDrainTimeout = 30 * time.Second
)

func (f *loopbackForwarder) acceptTCP(ctx context.Context, lis net.Listener, port uint16) {
backoff := acceptRetryInitialBackoff
// loggedAcceptError throttles per-listener Accept-error logs the
// same way logAddFailure throttles publish-failure logs in the
// scanner. Sustained FD pressure (EMFILE) saturates the loop at
// the 5 s cap; without the throttle, the loop emits one Error
// every 5 s indefinitely.
loggedAcceptError := false
for {
conn, err := lis.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
// Retry with exponential backoff on transient errors (EMFILE
// under FD pressure, ENOBUFS). The listener stays registered
// in f.tcp; once the pressure clears, Accept succeeds.
if !loggedAcceptError {
log.Errorf("loopback forwarder accept tcp/%d: %s (retry in %s)", port, err, backoff)
loggedAcceptError = true
} else {
log.Debugf("loopback forwarder accept tcp/%d: %s (retry in %s)", port, err, backoff)
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > acceptRetryMaxBackoff {
backoff = acceptRetryMaxBackoff
}
continue
}
backoff = acceptRetryInitialBackoff
loggedAcceptError = false
go f.pipeTCP(ctx, conn, port)
}
}

func (f *loopbackForwarder) pipeTCP(ctx context.Context, in net.Conn, port uint16) {
defer in.Close()
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(int(port)))
out, err := f.dialer.DialContext(ctx, protoTCP, addr)
if err != nil {
log.Debugf("loopback forwarder dial tcp/%d: %s", port, err)
return
}
defer out.Close()
// Half-close per direction so a client that signals end-of-request
// via CloseWrite still receives the upstream's response. After the
// first copy finishes, a drain deadline on both reads caps the wait
// for the other direction, so a wedged peer cannot leak this
// goroutine.
done := make(chan struct{}, 2)
copyDir := func(dst, src net.Conn) {
_, _ = io.Copy(dst, src)
if tcp, ok := dst.(*net.TCPConn); ok {
_ = tcp.CloseWrite()
}
done <- struct{}{}
}
go copyDir(out, in)
go copyDir(in, out)
<-done
drainDeadline := time.Now().Add(halfCloseDrainTimeout)
// Bound both reads (peer goes silent) and writes (peer's recv
// buffer fills) on whichever direction is still copying. A
// read-only deadline leaves a write stuck in io.Copy unbounded
// when the remaining peer applies TCP backpressure.
_ = in.SetReadDeadline(drainDeadline)
_ = out.SetReadDeadline(drainDeadline)
_ = in.SetWriteDeadline(drainDeadline)
_ = out.SetWriteDeadline(drainDeadline)
<-done
}
Loading
Loading