Merge pull request #48597 from robmry/nat-unprotected

Add gateway mode "nat-unprotected"
This commit is contained in:
Sebastiaan van Stijn
2024-11-29 16:42:28 +01:00
committed by GitHub
12 changed files with 289 additions and 39 deletions

View File

@@ -112,7 +112,7 @@ bigger change than it should be._
The DOCKER chain has a single DROP rule for the bridge network, to drop any
packets routed to the network that have not originated in the network. Added by
[defaultDrop][21].
[setDefaultForwardRule][21].
_This means there is no dependency on the filter-FORWARD chain's default policy.
Even if it is ACCEPT, packets will be dropped unless container ports/protocols
are published._

View File

@@ -0,0 +1,138 @@
## Container on a nat-unprotected network, with a published port
Running the daemon with the userland proxy disable then, as before, adding a network running a container with a mapped port, equivalent to:
docker network create \
-o com.docker.network.bridge.name=bridge1 \
-o com.docker.network.bridge.gateway_mode_ipv4=nat-unprotected \
--subnet 192.0.2.0/24 --gateway 192.0.2.1 bridge1
docker run --network bridge1 -p 8080:80 --name c1 busybox
The filter table is:
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER-USER 0 -- * * 0.0.0.0/0 0.0.0.0/0
2 0 0 ACCEPT 0 -- * * 0.0.0.0/0 0.0.0.0/0 match-set docker-ext-bridges-v4 dst ctstate RELATED,ESTABLISHED
3 0 0 DOCKER-ISOLATION-STAGE-1 0 -- * * 0.0.0.0/0 0.0.0.0/0
4 0 0 DOCKER 0 -- * * 0.0.0.0/0 0.0.0.0/0 match-set docker-ext-bridges-v4 dst
5 0 0 ACCEPT 0 -- bridge1 !bridge1 0.0.0.0/0 0.0.0.0/0
6 0 0 ACCEPT 0 -- bridge1 bridge1 0.0.0.0/0 0.0.0.0/0
7 0 0 ACCEPT 0 -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
8 0 0 ACCEPT 0 -- docker0 docker0 0.0.0.0/0 0.0.0.0/0
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
Chain DOCKER (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 DROP 0 -- !docker0 docker0 0.0.0.0/0 0.0.0.0/0
2 0 0 ACCEPT 0 -- !bridge1 bridge1 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-ISOLATION-STAGE-1 (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER-ISOLATION-STAGE-2 0 -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
2 0 0 DOCKER-ISOLATION-STAGE-2 0 -- bridge1 !bridge1 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-ISOLATION-STAGE-2 (2 references)
num pkts bytes target prot opt in out source destination
1 0 0 DROP 0 -- * bridge1 0.0.0.0/0 0.0.0.0/0
2 0 0 DROP 0 -- * docker0 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 RETURN 0 -- * * 0.0.0.0/0 0.0.0.0/0
<details>
<summary>iptables commands</summary>
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N DOCKER
-N DOCKER-ISOLATION-STAGE-1
-N DOCKER-ISOLATION-STAGE-2
-N DOCKER-USER
-A FORWARD -j DOCKER-USER
-A FORWARD -m set --match-set docker-ext-bridges-v4 dst -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -m set --match-set docker-ext-bridges-v4 dst -j DOCKER
-A FORWARD -i bridge1 ! -o bridge1 -j ACCEPT
-A FORWARD -i bridge1 -o bridge1 -j ACCEPT
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A DOCKER ! -i docker0 -o docker0 -j DROP
-A DOCKER ! -i bridge1 -o bridge1 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i bridge1 ! -o bridge1 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-2 -o bridge1 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-USER -j RETURN
</details>
Differences from [nat mode][400]:
- In the DOCKER chain:
- Where `nat` mode appended a default-DROP rule for any packets not accepted
by the per-port/protocol rules, `nat-unprotected` appends a default-ACCEPT
rule. [setDefaultForwardRule][402]
- The ACCEPT rule is needed in case the filter-FORWARD chain's default
policy is DROP.
- Because the default for this network is ACCEPT, there is no per-port/protocol
rule to ACCEPT packets for the published port `80/tcp`, [setPerPortIptables][401]
doesn't set it up.
- _If the userland proxy is enabled, it is still started._
The nat table is identical to [nat mode][400].
<details>
<summary>nat table</summary>
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER 0 -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER 0 -- * * 0.0.0.0/0 !127.0.0.0/8 ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 MASQUERADE 0 -- * !bridge1 192.0.2.0/24 0.0.0.0/0
2 0 0 MASQUERADE 0 -- * !docker0 172.17.0.0/16 0.0.0.0/0
Chain DOCKER (2 references)
num pkts bytes target prot opt in out source destination
1 0 0 RETURN 0 -- bridge1 * 0.0.0.0/0 0.0.0.0/0
2 0 0 RETURN 0 -- docker0 * 0.0.0.0/0 0.0.0.0/0
3 0 0 DNAT 6 -- !bridge1 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:192.0.2.2:80
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 192.0.2.0/24 ! -o bridge1 -j MASQUERADE
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i bridge1 -j RETURN
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i bridge1 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 192.0.2.2:80
</details>
[400]: usernet-portmap.md
[401]: https://github.com/robmry/moby/blob/52c89d467fc5326149e4bbb8903d23589b66ff0d/libnetwork/drivers/bridge/port_mapping_linux.go#L747
[402]: https://github.com/robmry/moby/blob/52c89d467fc5326149e4bbb8903d23589b66ff0d/libnetwork/drivers/bridge/setup_ip_tables_linux.go#L261-L266

View File

@@ -90,8 +90,8 @@ Note that:
(unlike all the other rules so-far, which were created during driver or
network initialisation). [setPerPortForwarding][1]
- These per-port rules are inserted at the head of the chain, so that they
appear before the network's DROP rule [defaultDrop][2] which is always
appended to the end of the chain. In this case, because `docker0` was
appear before the network's DROP rule [setDefaultForwardRule][2] which is
always appended to the end of the chain. In this case, because `docker0` was
created before `bridge1`, the `bridge1` rules appear above and below the
`docker0` DROP rule.

View File

@@ -44,3 +44,4 @@ Scenarios:
- [Container on a user-defined network with inter-container communication disabled, with a published port](generated/usernet-portmap-noicc.md)
- [Container on a user-defined --internal network](generated/usernet-internal.md)
- [Container on a routed-mode network, with a published port](generated/usernet-portmap-routed.md)
- [Container on a nat-unprotected network, with a published port](generated/usernet-portmap-natunprot.md)

View File

@@ -133,6 +133,19 @@ var index = []section{
},
}},
},
{
name: "usernet-portmap-natunprot.md",
networks: []bridgeNetwork{{
bridge: "bridge1",
gwMode: "nat-unprotected",
containers: []ctr{
{
name: "c1",
portMappings: nat.PortMap{"80/tcp": {{HostPort: "8080"}}},
},
},
}},
},
}
// iptCmdType is used to look up iptCmds in the markdown (can't use an int

View File

@@ -65,7 +65,7 @@ bigger change than it should be._
The DOCKER chain has a single DROP rule for the bridge network, to drop any
packets routed to the network that have not originated in the network. Added by
[defaultDrop][21].
[setDefaultForwardRule][21].
_This means there is no dependency on the filter-FORWARD chain's default policy.
Even if it is ACCEPT, packets will be dropped unless container ports/protocols
are published._

View File

@@ -0,0 +1,48 @@
## Container on a nat-unprotected network, with a published port
Running the daemon with the userland proxy disable then, as before, adding a network running a container with a mapped port, equivalent to:
docker network create \
-o com.docker.network.bridge.name=bridge1 \
-o com.docker.network.bridge.gateway_mode_ipv4=nat-unprotected \
--subnet 192.0.2.0/24 --gateway 192.0.2.1 bridge1
docker run --network bridge1 -p 8080:80 --name c1 busybox
The filter table is:
{{index . "LFilter4"}}
<details>
<summary>iptables commands</summary>
{{index . "SFilter4"}}
</details>
Differences from [nat mode][400]:
- In the DOCKER chain:
- Where `nat` mode appended a default-DROP rule for any packets not accepted
by the per-port/protocol rules, `nat-unprotected` appends a default-ACCEPT
rule. [setDefaultForwardRule][402]
- The ACCEPT rule is needed in case the filter-FORWARD chain's default
policy is DROP.
- Because the default for this network is ACCEPT, there is no per-port/protocol
rule to ACCEPT packets for the published port `80/tcp`, [setPerPortIptables][401]
doesn't set it up.
- _If the userland proxy is enabled, it is still started._
The nat table is identical to [nat mode][400].
<details>
<summary>nat table</summary>
{{index . "LNat4"}}
{{index . "SNat4"}}
</details>
[400]: usernet-portmap.md
[401]: https://github.com/robmry/moby/blob/52c89d467fc5326149e4bbb8903d23589b66ff0d/libnetwork/drivers/bridge/port_mapping_linux.go#L747
[402]: https://github.com/robmry/moby/blob/52c89d467fc5326149e4bbb8903d23589b66ff0d/libnetwork/drivers/bridge/setup_ip_tables_linux.go#L261-L266

View File

@@ -31,8 +31,8 @@ Note that:
(unlike all the other rules so-far, which were created during driver or
network initialisation). [setPerPortForwarding][1]
- These per-port rules are inserted at the head of the chain, so that they
appear before the network's DROP rule [defaultDrop][2] which is always
appended to the end of the chain. In this case, because `docker0` was
appear before the network's DROP rule [setDefaultForwardRule][2] which is
always appended to the end of the chain. In this case, because `docker0` was
created before `bridge1`, the `bridge1` rules appear above and below the
`docker0` DROP rule.

View File

@@ -10,6 +10,7 @@ import (
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
@@ -671,10 +672,14 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
// Run http servers on ports 80 and 81, but only map/open port 80.
createNet := func(gwMode string) ctrDesc {
netName := "test-" + gwMode
brName := "br-" + gwMode
if len(brName) > syscall.IFNAMSIZ {
brName = brName[:syscall.IFNAMSIZ-1]
}
network.CreateNoError(ctx, t, c, netName,
network.WithDriver("bridge"),
network.WithIPv6(),
network.WithOption(bridge.BridgeName, "br-"+gwMode),
network.WithOption(bridge.BridgeName, brName),
network.WithOption(bridge.IPv4GatewayMode, gwMode),
network.WithOption(bridge.IPv6GatewayMode, gwMode),
)
@@ -711,12 +716,19 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
)
networks := map[string]ctrDesc{
"nat": createNet("nat"),
"routed": createNet("routed"),
"nat": createNet("nat"),
"nat-unprotected": createNet("nat-unprotected"),
"routed": createNet("routed"),
}
expPingExit := map[string]int{
"nat": pingFail,
"routed": pingSuccess,
"nat": pingFail,
"nat-unprotected": pingSuccess,
"routed": pingSuccess,
}
expUnmappedPortHTTP := map[string]string{
"nat": httpFail,
"nat-unprotected": httpSuccess,
"routed": httpFail,
}
testPing := func(t *testing.T, cmd, addr string, expExit int) {
@@ -746,7 +758,7 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
for _, fwdPolicy := range []string{"ACCEPT", "DROP"} {
networking.SetFilterForwardPolicies(t, fwdPolicy)
t.Run(fwdPolicy, func(t *testing.T) {
for _, gwMode := range []string{"nat", "routed"} {
for gwMode := range networks {
t.Run(gwMode+"/v4/ping", func(t *testing.T) {
testPing(t, "ping", networks[gwMode].ipv4, expPingExit[gwMode])
})
@@ -757,13 +769,13 @@ func TestDirectRoutingOpenPorts(t *testing.T) {
testHttp(t, networks[gwMode].ipv4, "80", httpSuccess)
})
t.Run(gwMode+"/v4/http/81", func(t *testing.T) {
testHttp(t, networks[gwMode].ipv4, "81", httpFail)
testHttp(t, networks[gwMode].ipv4, "81", expUnmappedPortHTTP[gwMode])
})
t.Run(gwMode+"/v6/http/80", func(t *testing.T) {
testHttp(t, networks[gwMode].ipv6, "80", httpSuccess)
})
t.Run(gwMode+"/v6/http/81", func(t *testing.T) {
testHttp(t, networks[gwMode].ipv6, "81", httpFail)
testHttp(t, networks[gwMode].ipv6, "81", expUnmappedPortHTTP[gwMode])
})
}
})

View File

@@ -167,9 +167,10 @@ type driver struct {
type gwMode string
const (
gwModeDefault gwMode = ""
gwModeNAT gwMode = "nat"
gwModeRouted gwMode = "routed"
gwModeDefault gwMode = ""
gwModeNAT gwMode = "nat"
gwModeNATUnprot gwMode = "nat-unprotected"
gwModeRouted gwMode = "routed"
)
// New constructs a new bridge driver
@@ -366,6 +367,8 @@ func newGwMode(gwMode string) (gwMode, error) {
switch gwMode {
case "nat":
return gwModeNAT, nil
case "nat-unprotected":
return gwModeNATUnprot, nil
case "routed":
return gwModeRouted, nil
}
@@ -376,6 +379,10 @@ func (m gwMode) routed() bool {
return m == gwModeRouted
}
func (m gwMode) unprotected() bool {
return m == gwModeNATUnprot
}
func parseErr(label, value, errString string) error {
return types.InvalidParameterErrorf("failed to parse %s value: %v (%s)", label, value, errString)
}
@@ -428,6 +435,15 @@ func (n *bridgeNetwork) getNATDisabled() (ipv4, ipv6 bool) {
return n.config.GwModeIPv4.routed(), n.config.GwModeIPv6.routed()
}
func (n *bridgeNetwork) gwMode(v iptables.IPVersion) gwMode {
n.Lock()
defer n.Unlock()
if v == iptables.IPv4 {
return n.config.GwModeIPv4
}
return n.config.GwModeIPv6
}
func (n *bridgeNetwork) userlandProxyPath() string {
n.Lock()
defer n.Unlock()

View File

@@ -776,8 +776,11 @@ func (n *bridgeNetwork) setPerPortIptables(b portBinding, enable bool) error {
if err := setPerPortNAT(b, v, proxyPath, bridgeName, enable); err != nil {
return err
}
if err := setPerPortForwarding(b, v, bridgeName, enable); err != nil {
return err
if !n.gwMode(v).unprotected() {
if err := setPerPortForwarding(b, v, bridgeName, enable); err != nil {
return err
}
}
return nil
}

View File

@@ -166,14 +166,14 @@ func (n *bridgeNetwork) setupIPTables(ipVersion iptables.IPVersion, maskedAddr *
if config.Internal {
if err = setupInternalNetworkRules(config.BridgeName, maskedAddr, config.EnableICC, true); err != nil {
return fmt.Errorf("Failed to Setup IP tables: %s", err.Error())
return fmt.Errorf("Failed to Setup IP tables: %w", err)
}
n.registerIptCleanFunc(func() error {
return setupInternalNetworkRules(config.BridgeName, maskedAddr, config.EnableICC, false)
})
} else {
if err = setupIPTablesInternal(ipVersion, config, maskedAddr, hairpinMode, true); err != nil {
return fmt.Errorf("Failed to Setup IP tables: %s", err.Error())
return fmt.Errorf("Failed to Setup IP tables: %w", err)
}
n.registerIptCleanFunc(func() error {
return setupIPTablesInternal(ipVersion, config, maskedAddr, hairpinMode, false)
@@ -181,28 +181,25 @@ func (n *bridgeNetwork) setupIPTables(ipVersion iptables.IPVersion, maskedAddr *
natChain, filterChain, _, _, err := n.getDriverChains(ipVersion)
if err != nil {
return fmt.Errorf("Failed to setup IP tables, cannot acquire chain info %s", err.Error())
return fmt.Errorf("Failed to setup IP tables, cannot acquire chain info %w", err)
}
err = iptable.ProgramChain(natChain, config.BridgeName, hairpinMode, true)
if err != nil {
return fmt.Errorf("Failed to program NAT chain: %s", err.Error())
return fmt.Errorf("Failed to program NAT chain: %w", err)
}
err = iptable.ProgramChain(filterChain, config.BridgeName, hairpinMode, true)
if err != nil {
return fmt.Errorf("Failed to program FILTER chain: %s", err.Error())
return fmt.Errorf("Failed to program FILTER chain: %w", err)
}
n.registerIptCleanFunc(func() error {
return iptable.ProgramChain(filterChain, config.BridgeName, hairpinMode, false)
})
if err := defaultDrop(ipVersion, config.BridgeName, true); err != nil {
return fmt.Errorf("failed to add default-drop rule: %s", err.Error())
if err := n.setDefaultForwardRule(ipVersion, config.BridgeName); err != nil {
return err
}
n.registerIptCleanFunc(func() error {
return defaultDrop(ipVersion, config.BridgeName, false)
})
cidr, _ := maskedAddr.Mask.Size()
if cidr == 0 {
@@ -252,15 +249,37 @@ func setICMP(ipv iptables.IPVersion, bridgeName string, enable bool) error {
return appendOrDelChainRule(icmpRule, "ICMP", enable)
}
// Append to the filter table's DOCKER chain (the default DROP rule must follow
// per-port ACCEPT rules, which will be inserted at the top of the chain).
func defaultDrop(ipv iptables.IPVersion, bridgeName string, enable bool) error {
dropRule := iptRule{ipv: ipv, table: iptables.Filter, chain: DockerChain, args: []string{
func (n *bridgeNetwork) setDefaultForwardRule(
ipVersion iptables.IPVersion,
bridgeName string,
) error {
// Normally, DROP anything that hasn't been ACCEPTed by a per-port/protocol
// rule. This prevents direct access to un-mapped ports from remote hosts
// that can route directly to the container's address (by setting up a
// route via the host's address).
action := "DROP"
if n.gwMode(ipVersion).unprotected() {
// If the user really wants to allow all access from the wider network,
// explicitly ACCEPT anything so that the filter-FORWARD chain's
// default policy can't interfere.
action = "ACCEPT"
}
rule := iptRule{ipv: ipVersion, table: iptables.Filter, chain: DockerChain, args: []string{
"!", "-i", bridgeName,
"-o", bridgeName,
"-j", "DROP",
"-j", action,
}}
return appendOrDelChainRule(dropRule, "DEFAULT DROP", enable)
// Append to the filter table's DOCKER chain (the default rule must follow
// per-port ACCEPT rules, which will be inserted at the top of the chain).
if err := appendOrDelChainRule(rule, "DEFAULT FWD", true); err != nil {
return fmt.Errorf("failed to add default-drop rule: %w", err)
}
n.registerIptCleanFunc(func() error {
return appendOrDelChainRule(rule, "DEFAULT FWD", false)
})
return nil
}
type iptRule struct {
@@ -387,7 +406,7 @@ func programChainRule(rule iptRule, ruleDescr string, insert bool) error {
fn = rule.Insert
}
if err := fn(); err != nil {
return fmt.Errorf("Unable to %s %s rule: %s", operation, ruleDescr, err.Error())
return fmt.Errorf("Unable to %s %s rule: %w", operation, ruleDescr, err)
}
return nil
}
@@ -400,7 +419,7 @@ func appendOrDelChainRule(rule iptRule, ruleDescr string, append bool) error {
fn = rule.Append
}
if err := fn(); err != nil {
return fmt.Errorf("Unable to %s %s rule: %s", operation, ruleDescr, err.Error())
return fmt.Errorf("Unable to %s %s rule: %w", operation, ruleDescr, err)
}
return nil
}
@@ -413,12 +432,12 @@ func setIcc(version iptables.IPVersion, bridgeIface string, iccEnable, insert bo
if !iccEnable {
acceptRule.Delete()
if err := dropRule.Append(); err != nil {
return fmt.Errorf("Unable to prevent intercontainer communication: %s", err.Error())
return fmt.Errorf("Unable to prevent intercontainer communication: %w", err)
}
} else {
dropRule.Delete()
if err := acceptRule.Insert(); err != nil {
return fmt.Errorf("Unable to allow intercontainer communication: %s", err.Error())
return fmt.Errorf("Unable to allow intercontainer communication: %w", err)
}
}
} else {