Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do not DNAT packets from WSL2's loopback0 #48075

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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"},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As (even more with my suggestions), the magic loopback0 interface will be used more now, I'm wondering it would make sense to define a const for it as well; at least it would allow documenting "what's this magic loopback0?

(downside is that it's less grep'able for loopback0 in the code, although the const can still be a good starting point I guess)

So, yeah, not a "strong" opinion one way of another 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried it with a const name ... but I don't think it made things any clearer, so put it back.

The gigantic comment just above explains what it is.

}
}
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))
})
}
}
Loading