Skip to content

Commit

Permalink
Do not DNAT packets from WSL2's loopback0
Browse files Browse the repository at this point in the history
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 <[email protected]>
(cherry picked from commit f9c0103)
Signed-off-by: Rob Murray <[email protected]>
  • Loading branch information
robmry committed Sep 17, 2024
1 parent 8516f3b commit cd21af7
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 0 deletions.
88 changes: 88 additions & 0 deletions libnetwork/drivers/bridge/setup_ip_tables_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"os"
"strings"

"github.com/containerd/log"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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"},
}
}
74 changes: 74 additions & 0 deletions libnetwork/drivers/bridge/setup_ip_tables_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package bridge

import (
"net"
"os"
"path/filepath"
"testing"

"github.com/docker/docker/internal/testutils/netnsutils"
Expand All @@ -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 (
Expand Down Expand Up @@ -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))
})
}
}

0 comments on commit cd21af7

Please sign in to comment.