Do not DNAT packets from WSL2's loopback0

When running WSL2 with mirrored mode networking, add an iptables
rule to skip DNAT for packets arriving on interface loopback0 that
are addressed to a localhost address - they're from the Windows
host.

Signed-off-by: Rob Murray <rob.murray@docker.com>
This commit is contained in:
Rob Murray
2024-06-27 13:38:20 +01:00
parent ff1e2c0de7
commit f9c0103413
2 changed files with 162 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"os"
"strings"
"github.com/containerd/log"
@@ -32,6 +33,11 @@ const (
IsolationChain2 = "DOCKER-ISOLATION-STAGE-2"
)
// Path to the executable installed in Linux under WSL2 that reports on
// WSL config. https://github.com/microsoft/WSL/releases/tag/2.0.4
// Can be modified by tests.
var wslinfoPath = "/usr/bin/wslinfo"
func setupIPChains(config configuration, version iptables.IPVersion) (natChain *iptables.ChainInfo, filterChain *iptables.ChainInfo, isolationChain1 *iptables.ChainInfo, isolationChain2 *iptables.ChainInfo, retErr error) {
// Sanity check.
if version == iptables.IPv4 && !config.EnableIPTables {
@@ -99,6 +105,10 @@ func setupIPChains(config configuration, version iptables.IPVersion) (natChain *
return nil, nil, nil, nil, err
}
if err := mirroredWSL2Workaround(config, version); err != nil {
return nil, nil, nil, nil, err
}
return natChain, filterChain, isolationChain1, isolationChain2, nil
}
@@ -502,3 +512,81 @@ func clearConntrackEntries(nlh *netlink.Handle, ep *bridgeEndpoint) {
iptables.DeleteConntrackEntries(nlh, ipv4List, ipv6List)
iptables.DeleteConntrackEntriesByPort(nlh, types.UDP, udpPorts)
}
// mirroredWSL2Workaround adds or removes an IPv4 NAT rule, depending on whether
// docker's host Linux appears to be a guest running under WSL2 in with mirrored
// mode networking.
// https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking
//
// Without mirrored mode networking, or for a packet sent from Linux, packets
// sent to 127.0.0.1 are processed as outgoing - they hit the nat-OUTPUT chain,
// which does not jump to the nat-DOCKER chain because the rule has an exception
// for "-d 127.0.0.0/8". The default action on the nat-OUTPUT chain is ACCEPT (by
// default), so the packet is delivered to 127.0.0.1 on lo, where docker-proxy
// picks it up and acts as a man-in-the-middle; it receives the packet and
// re-sends it to the container (or acks a SYN and sets up a second TCP
// connection to the container). So, the container sees packets arrive with a
// source address belonging to the network's bridge, and it is able to reply to
// that address.
//
// In WSL2's mirrored networking mode, Linux has a loopback0 device as well as lo
// (which owns 127.0.0.1 as normal). Packets sent to 127.0.0.1 from Windows to a
// server listening on Linux's 127.0.0.1 are delivered via loopback0, and
// processed as packets arriving from outside the Linux host (which they are).
//
// So, these packets hit the nat-PREROUTING chain instead of nat-OUTPUT. It would
// normally be impossible for a packet ->127.0.0.1 to arrive from outside the
// host, so the nat-PREROUTING jump to nat-DOCKER has no exception for it. The
// packet is processed by a per-bridge DNAT rule in that chain, so it is
// delivered directly to the container (not via docker-proxy) with source address
// 127.0.0.1, so the container can't respond.
//
// DNAT is normally skipped by RETURN rules in the nat-DOCKER chain for packets
// arriving from any other bridge network. Similarly, this function adds (or
// removes) a rule to RETURN early for packets delivered via loopback0 with
// destination 127.0.0.0/8.
func mirroredWSL2Workaround(config configuration, ipv iptables.IPVersion) error {
// WSL2 does not (currently) support Windows<->Linux communication via ::1.
if ipv != iptables.IPv4 {
return nil
}
return programChainRule(mirroredWSL2Rule(), "WSL2 loopback", insertMirroredWSL2Rule(config))
}
// insertMirroredWSL2Rule returns true if the NAT rule for mirrored WSL2 workaround
// is required. It is required if:
// - the userland proxy is running. If not, there's nothing on the host to catch
// the packet, so the loopback0 rule as wouldn't be useful. However, without
// the workaround, with improvements in WSL2 v2.3.11, and without userland proxy
// running - no workaround is needed, the normal DNAT/masquerading works.
// - and, the host Linux appears to be running under Windows WSL2 with mirrored
// mode networking. If a loopback0 device exists, and there's an executable at
// /usr/bin/wslinfo, infer that this is WSL2 with mirrored networking. ("wslinfo
// --networking-mode" reports "mirrored", but applying the workaround for WSL2's
// loopback device when it's not needed is low risk, compared with executing
// wslinfo with dockerd's elevated permissions.)
func insertMirroredWSL2Rule(config configuration) bool {
if !config.EnableUserlandProxy || config.UserlandProxyPath == "" {
return false
}
if _, err := netlink.LinkByName("loopback0"); err != nil {
if !errors.As(err, &netlink.LinkNotFoundError{}) {
log.G(context.TODO()).WithError(err).Warn("Failed to check for WSL interface")
}
return false
}
stat, err := os.Stat(wslinfoPath)
if err != nil {
return false
}
return stat.Mode().IsRegular() && (stat.Mode().Perm()&0111) != 0
}
func mirroredWSL2Rule() iptRule {
return iptRule{
ipv: iptables.IPv4,
table: iptables.Nat,
chain: DockerChain,
args: []string{"-i", "loopback0", "-d", "127.0.0.0/8", "-j", "RETURN"},
}
}

View File

@@ -2,6 +2,8 @@ package bridge
import (
"net"
"os"
"path/filepath"
"testing"
"github.com/docker/docker/internal/testutils/netnsutils"
@@ -10,6 +12,7 @@ import (
"github.com/docker/docker/libnetwork/netlabel"
"github.com/vishvananda/netlink"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
const (
@@ -374,3 +377,74 @@ func TestOutgoingNATRules(t *testing.T) {
})
}
}
func TestMirroredWSL2Workaround(t *testing.T) {
for _, tc := range []struct {
desc string
loopback0 bool
userlandProxy bool
wslinfoPerm os.FileMode // 0 for no-file
expLoopback0Rule bool
}{
{
desc: "No loopback0",
},
{
desc: "WSL2 mirrored",
loopback0: true,
userlandProxy: true,
wslinfoPerm: 0777,
expLoopback0Rule: true,
},
{
desc: "loopback0 but wslinfo not executable",
loopback0: true,
userlandProxy: true,
wslinfoPerm: 0666,
},
{
desc: "loopback0 but no wslinfo",
loopback0: true,
userlandProxy: true,
},
{
desc: "loopback0 but no userland proxy",
loopback0: true,
wslinfoPerm: 0777,
},
} {
t.Run(tc.desc, func(t *testing.T) {
defer netnsutils.SetupTestOSContext(t)()
if tc.loopback0 {
loopback0 := &netlink.Dummy{
LinkAttrs: netlink.LinkAttrs{
Name: "loopback0",
},
}
err := netlink.LinkAdd(loopback0)
assert.NilError(t, err)
}
if tc.wslinfoPerm != 0 {
wslinfoPathOrig := wslinfoPath
defer func() {
wslinfoPath = wslinfoPathOrig
}()
tmpdir := t.TempDir()
wslinfoPath = filepath.Join(tmpdir, "wslinfo")
err := os.WriteFile(wslinfoPath, []byte("#!/bin/sh\necho dummy file\n"), tc.wslinfoPerm)
assert.NilError(t, err)
}
config := configuration{EnableIPTables: true}
if tc.userlandProxy {
config.UserlandProxyPath = "some-proxy"
config.EnableUserlandProxy = true
}
_, _, _, _, err := setupIPChains(config, iptables.IPv4)
assert.NilError(t, err)
assert.Check(t, is.Equal(mirroredWSL2Rule().Exists(), tc.expLoopback0Rule))
})
}
}