Skip to content

Commit cd21af7

Browse files
committed
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 <[email protected]> (cherry picked from commit f9c0103) Signed-off-by: Rob Murray <[email protected]>
1 parent 8516f3b commit cd21af7

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
lines changed

libnetwork/drivers/bridge/setup_ip_tables_linux.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net"
8+
"os"
89
"strings"
910

1011
"github.com/containerd/log"
@@ -32,6 +33,11 @@ const (
3233
IsolationChain2 = "DOCKER-ISOLATION-STAGE-2"
3334
)
3435

36+
// Path to the executable installed in Linux under WSL2 that reports on
37+
// WSL config. https://github.com/microsoft/WSL/releases/tag/2.0.4
38+
// Can be modified by tests.
39+
var wslinfoPath = "/usr/bin/wslinfo"
40+
3541
func setupIPChains(config configuration, version iptables.IPVersion) (natChain *iptables.ChainInfo, filterChain *iptables.ChainInfo, isolationChain1 *iptables.ChainInfo, isolationChain2 *iptables.ChainInfo, retErr error) {
3642
// Sanity check.
3743
if version == iptables.IPv4 && !config.EnableIPTables {
@@ -99,6 +105,10 @@ func setupIPChains(config configuration, version iptables.IPVersion) (natChain *
99105
return nil, nil, nil, nil, err
100106
}
101107

108+
if err := mirroredWSL2Workaround(config, version); err != nil {
109+
return nil, nil, nil, nil, err
110+
}
111+
102112
return natChain, filterChain, isolationChain1, isolationChain2, nil
103113
}
104114

@@ -502,3 +512,81 @@ func clearConntrackEntries(nlh *netlink.Handle, ep *bridgeEndpoint) {
502512
iptables.DeleteConntrackEntries(nlh, ipv4List, ipv6List)
503513
iptables.DeleteConntrackEntriesByPort(nlh, types.UDP, udpPorts)
504514
}
515+
516+
// mirroredWSL2Workaround adds or removes an IPv4 NAT rule, depending on whether
517+
// docker's host Linux appears to be a guest running under WSL2 in with mirrored
518+
// mode networking.
519+
// https://learn.microsoft.com/en-us/windows/wsl/networking#mirrored-mode-networking
520+
//
521+
// Without mirrored mode networking, or for a packet sent from Linux, packets
522+
// sent to 127.0.0.1 are processed as outgoing - they hit the nat-OUTPUT chain,
523+
// which does not jump to the nat-DOCKER chain because the rule has an exception
524+
// for "-d 127.0.0.0/8". The default action on the nat-OUTPUT chain is ACCEPT (by
525+
// default), so the packet is delivered to 127.0.0.1 on lo, where docker-proxy
526+
// picks it up and acts as a man-in-the-middle; it receives the packet and
527+
// re-sends it to the container (or acks a SYN and sets up a second TCP
528+
// connection to the container). So, the container sees packets arrive with a
529+
// source address belonging to the network's bridge, and it is able to reply to
530+
// that address.
531+
//
532+
// In WSL2's mirrored networking mode, Linux has a loopback0 device as well as lo
533+
// (which owns 127.0.0.1 as normal). Packets sent to 127.0.0.1 from Windows to a
534+
// server listening on Linux's 127.0.0.1 are delivered via loopback0, and
535+
// processed as packets arriving from outside the Linux host (which they are).
536+
//
537+
// So, these packets hit the nat-PREROUTING chain instead of nat-OUTPUT. It would
538+
// normally be impossible for a packet ->127.0.0.1 to arrive from outside the
539+
// host, so the nat-PREROUTING jump to nat-DOCKER has no exception for it. The
540+
// packet is processed by a per-bridge DNAT rule in that chain, so it is
541+
// delivered directly to the container (not via docker-proxy) with source address
542+
// 127.0.0.1, so the container can't respond.
543+
//
544+
// DNAT is normally skipped by RETURN rules in the nat-DOCKER chain for packets
545+
// arriving from any other bridge network. Similarly, this function adds (or
546+
// removes) a rule to RETURN early for packets delivered via loopback0 with
547+
// destination 127.0.0.0/8.
548+
func mirroredWSL2Workaround(config configuration, ipv iptables.IPVersion) error {
549+
// WSL2 does not (currently) support Windows<->Linux communication via ::1.
550+
if ipv != iptables.IPv4 {
551+
return nil
552+
}
553+
return programChainRule(mirroredWSL2Rule(), "WSL2 loopback", insertMirroredWSL2Rule(config))
554+
}
555+
556+
// insertMirroredWSL2Rule returns true if the NAT rule for mirrored WSL2 workaround
557+
// is required. It is required if:
558+
// - the userland proxy is running. If not, there's nothing on the host to catch
559+
// the packet, so the loopback0 rule as wouldn't be useful. However, without
560+
// the workaround, with improvements in WSL2 v2.3.11, and without userland proxy
561+
// running - no workaround is needed, the normal DNAT/masquerading works.
562+
// - and, the host Linux appears to be running under Windows WSL2 with mirrored
563+
// mode networking. If a loopback0 device exists, and there's an executable at
564+
// /usr/bin/wslinfo, infer that this is WSL2 with mirrored networking. ("wslinfo
565+
// --networking-mode" reports "mirrored", but applying the workaround for WSL2's
566+
// loopback device when it's not needed is low risk, compared with executing
567+
// wslinfo with dockerd's elevated permissions.)
568+
func insertMirroredWSL2Rule(config configuration) bool {
569+
if !config.EnableUserlandProxy || config.UserlandProxyPath == "" {
570+
return false
571+
}
572+
if _, err := netlink.LinkByName("loopback0"); err != nil {
573+
if !errors.As(err, &netlink.LinkNotFoundError{}) {
574+
log.G(context.TODO()).WithError(err).Warn("Failed to check for WSL interface")
575+
}
576+
return false
577+
}
578+
stat, err := os.Stat(wslinfoPath)
579+
if err != nil {
580+
return false
581+
}
582+
return stat.Mode().IsRegular() && (stat.Mode().Perm()&0111) != 0
583+
}
584+
585+
func mirroredWSL2Rule() iptRule {
586+
return iptRule{
587+
ipv: iptables.IPv4,
588+
table: iptables.Nat,
589+
chain: DockerChain,
590+
args: []string{"-i", "loopback0", "-d", "127.0.0.0/8", "-j", "RETURN"},
591+
}
592+
}

libnetwork/drivers/bridge/setup_ip_tables_linux_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package bridge
22

33
import (
44
"net"
5+
"os"
6+
"path/filepath"
57
"testing"
68

79
"github.com/docker/docker/internal/testutils/netnsutils"
@@ -10,6 +12,7 @@ import (
1012
"github.com/docker/docker/libnetwork/netlabel"
1113
"github.com/vishvananda/netlink"
1214
"gotest.tools/v3/assert"
15+
is "gotest.tools/v3/assert/cmp"
1316
)
1417

1518
const (
@@ -374,3 +377,74 @@ func TestOutgoingNATRules(t *testing.T) {
374377
})
375378
}
376379
}
380+
381+
func TestMirroredWSL2Workaround(t *testing.T) {
382+
for _, tc := range []struct {
383+
desc string
384+
loopback0 bool
385+
userlandProxy bool
386+
wslinfoPerm os.FileMode // 0 for no-file
387+
expLoopback0Rule bool
388+
}{
389+
{
390+
desc: "No loopback0",
391+
},
392+
{
393+
desc: "WSL2 mirrored",
394+
loopback0: true,
395+
userlandProxy: true,
396+
wslinfoPerm: 0777,
397+
expLoopback0Rule: true,
398+
},
399+
{
400+
desc: "loopback0 but wslinfo not executable",
401+
loopback0: true,
402+
userlandProxy: true,
403+
wslinfoPerm: 0666,
404+
},
405+
{
406+
desc: "loopback0 but no wslinfo",
407+
loopback0: true,
408+
userlandProxy: true,
409+
},
410+
{
411+
desc: "loopback0 but no userland proxy",
412+
loopback0: true,
413+
wslinfoPerm: 0777,
414+
},
415+
} {
416+
t.Run(tc.desc, func(t *testing.T) {
417+
defer netnsutils.SetupTestOSContext(t)()
418+
419+
if tc.loopback0 {
420+
loopback0 := &netlink.Dummy{
421+
LinkAttrs: netlink.LinkAttrs{
422+
Name: "loopback0",
423+
},
424+
}
425+
err := netlink.LinkAdd(loopback0)
426+
assert.NilError(t, err)
427+
}
428+
429+
if tc.wslinfoPerm != 0 {
430+
wslinfoPathOrig := wslinfoPath
431+
defer func() {
432+
wslinfoPath = wslinfoPathOrig
433+
}()
434+
tmpdir := t.TempDir()
435+
wslinfoPath = filepath.Join(tmpdir, "wslinfo")
436+
err := os.WriteFile(wslinfoPath, []byte("#!/bin/sh\necho dummy file\n"), tc.wslinfoPerm)
437+
assert.NilError(t, err)
438+
}
439+
440+
config := configuration{EnableIPTables: true}
441+
if tc.userlandProxy {
442+
config.UserlandProxyPath = "some-proxy"
443+
config.EnableUserlandProxy = true
444+
}
445+
_, _, _, _, err := setupIPChains(config, iptables.IPv4)
446+
assert.NilError(t, err)
447+
assert.Check(t, is.Equal(mirroredWSL2Rule().Exists(), tc.expLoopback0Rule))
448+
})
449+
}
450+
}

0 commit comments

Comments
 (0)