Skip to content

Conversation

@robmry
Copy link
Contributor

@robmry robmry commented Oct 7, 2024

- What I did

Add gateway mode nat-unprotected for use in option com.docker.network.bridge.gateway_mode_ipv[46].

A nat-unprotected network:

  • has masquerading and port publishing to the host (like the default nat gateway mode).
  • has no port/protocol filtering for direct access to the container's address from remote hosts (unlike nat).

nat-unprotected mode can be used to restore the old default behaviour for IPv4 when the filter-FORWARD chain's default policy was "ACCEPT". (Before #48594, the daemon would only set the policy to "DROP" when it set sysctl "ip_forward" itself. Now, it never touches the default policy.)

https://docs.docker.com/engine/network/packet-filtering-firewalls/#direct-routing will need an update.

- How I did it

In the DOCKER chain, where nat mode appends a default-DROP rule for any packets not accepted by the per-port/protocol rules, nat-unprotected appends a default-ACCEPT rule (needed in case the filter-FORWARD chain's default policy is DROP).

Because the default is ACCEPT, no per-port/protocol rules to ACCEPT packets for the published ports are created. (If the userland proxy is enabled, it is still started.)

- How to verify it

New tests.

- Description for the changelog

- bridge driver options `com.docker.network.bridge.gateway_mode_ipv4` and `com.docker.network.bridge.gateway_mode_ipv6` now accept mode `nat-unprotected`.
  - `nat-unprotected` is similar to the default `nat` mode, but no per port/protocol iptables rules are set up.
  - this means any port on a container can be accessed by direct-routing from a remote host.

@robmry robmry added kind/enhancement Enhancements are not bugs or new features but can improve usability or performance. area/networking Networking impact/changelog impact/documentation area/networking/firewalling Networking area/networking/d/bridge Networking area/networking/portmapping Networking labels Oct 7, 2024
@robmry robmry added this to the 28.0.0 milestone Oct 7, 2024
@robmry robmry self-assigned this Oct 7, 2024
@robmry robmry force-pushed the nat-unprotected branch 6 times, most recently from be62866 to 393d4a0 Compare October 11, 2024 10:55
@robmry robmry force-pushed the nat-unprotected branch 2 times, most recently from 826284d to a284fda Compare October 24, 2024 14:09
@robmry robmry force-pushed the nat-unprotected branch 2 times, most recently from b11455a to 7d85aa8 Compare November 19, 2024 17:20
@robmry robmry marked this pull request as ready for review November 19, 2024 19:03
@robmry robmry requested a review from akerouanton November 19, 2024 19:03
return n.config.GwModeIPv4.routed(), n.config.GwModeIPv6.routed()
}

func (n *bridgeNetwork) getUnprotected(v iptables.IPVersion) bool {
Copy link
Member

Choose a reason for hiding this comment

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

I'm pretty sure we'll find this name confusing some time in the future. Maybe isNATUnprotected would be more explicit?

Suggested change
func (n *bridgeNetwork) getUnprotected(v iptables.IPVersion) bool {
func (n *bridgeNetwork) isNATUnprotected(v iptables.IPVersion) bool {

Or maybe you can introduce a more generic method: func (n *bridgeNetwork) isGwMode(gwm gwMode) bool 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, yes - I see your point! I think I had routed-unprotected in-mind (which doesn't exist, but might at some point) ... so, this method would return true for *-unprotected.

I'll change it to isGwModeUnprotected.

gwModeRouted gwMode = "routed"
gwModeDefault gwMode = ""
gwModeNAT gwMode = "nat"
gwModeNATUnprot gwMode = "nat-unprotected"
Copy link
Member

Choose a reason for hiding this comment

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

I think we need to make nat-unprotected the default for existing networks -- only new networks should use nat. At least, that's what it should be for Docker Desktop (and DD doesn't have a way to 'migrate' Docker objects during upgrades).

Copy link
Contributor Author

@robmry robmry Nov 22, 2024

Choose a reason for hiding this comment

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

Noting our discussion here ...

Setting nat-unprotected for existing IPv4 networks would be right if the host had previously been running with the filter-FORWARD chain's policy set to ACCEPT ... which happens when sysctl ip_forward is enabled before the daemon starts.

But, when it's set to DROP, and for IPv6 - only published ports would be exposed. So, we'd silently be making things worse in many cases by defaulting to nat-unprotected.

So, the change to the default nat mode will break a few setups by blocking remote access to unpublished ports, and anyone who's relying on that will have to re-create their networks with the new mode - but, at least it's fail-safe, and this change will block remote access for users (possibly the majority) who hadn't realised those ports were open.

Copy link
Member

@akerouanton akerouanton left a comment

Choose a reason for hiding this comment

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

LGTM

@robmry
Copy link
Contributor Author

robmry commented Nov 28, 2024

Rebased - which got rid of the first commit, it was just gofumpt reformatting.

@robmry
Copy link
Contributor Author

robmry commented Nov 28, 2024

Unrelated-looking test failure ...

=== FAIL: github.com/docker/docker/integration/container TestLogs/driver_json-file/tty/only_stdout (60.43s)
    logs_test.go:133: assertion failed: error is not nil: error during connect: Post "http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.48/containers/create": EOF


docker network create \
-o com.docker.network.bridge.name=bridge1 \
-o com.docker.network.bridge.gateway_mode_ipv4=nat-unprotected \
Copy link
Member

Choose a reason for hiding this comment

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

The reason we need an option to explicitly set this for IPv4/IPv6 is that we changed the default for IPv6 already (but not IPv4 for backward-compat), correct?

(Mostly trying to get my head around what's needed to make the overall experience more aligned in future, and possibly reduce some complexity in codepaths)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Defaults haven't changed ... in 27.0 (#47871) we introduced the IPv4 and IPv6 gateway mode options - nat became a new name for the old behaviour, and routed mode was added. The default is still nat for both IPv4 and IPv6.

For IPv4, NAT is normal, so you might want to publish ports to the host. For IPv6, direct routing is preferred and, if you have the routing set up in your network, you might want to disable NAT. So, they're configurable separately.

In 28.0, we're changing the default behaviour (nat mode) by blocking access to unpublished ports from other hosts on the same layer-2 network that have set up a route to the container network ("direct routing"). Previously, these ports were only blocked if the filter-FORWARD chain's default policy was DROP. But, docker only sets to DROP if it enables IP forwarding itself.

The old behaviour for a host with filter-FORWARD policy ACCEPT is used as a feature, so we're adding nat-unprotected as a way to restore the old nat behaviour.

It's possible to use nat-unprotected for IPv6, but its filter-FORWARD policy was always set to DROP when ip6tables was enabled - so the new restrictions in nat mode are less likely to be breaking changes.

Comment on lines +439 to +440
n.Lock()
defer n.Unlock()
Copy link
Member

Choose a reason for hiding this comment

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

Mostly curious; we have so much locking everywhere; do we have good insight what exactly is it synchronising? (was contemplating if it's purely n.config, and if we should have an accessor for that to return it 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I think it's protecting concurrent access to n.config ... an accessor to fetch a copy of the whole of config might be better - but most of the locking is probably unnecessary because the config won't change once created.

But, when adding new accessors, I'm sticking with the scheme others us - it needs proper review and tidy-up and, for me, the cost of acquiring the mutex is outweighed by the risk of making it crashy by missing something!

if err := setPerPortForwarding(b, v, bridgeName, enable); err != nil {
return err

if !n.isGwModeUnprotected(v) {
Copy link
Member

Choose a reason for hiding this comment

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

Really nit-picky, but I had to look 2 levels deep to see the exact details;

  • we need isGwModeUnprotected to select the gateway mode for either IPv4 or IPv6
  • n.config.GwModeIPv6 / n.config.GwModeIPv4 are a gwMode, which is effectively a glorified string, but we started to dig in to adding gwMode.something() bool accessors, which just compare it

Wondering if it would make sense to have a n.gatewayMode(v iptables.IPVersion) gwMode method, which would allow comparing it where we need;

if n.gateWayMode(v) == gwModeNATUnprot {
  // ...
}

or for cases where there's multiple code-paths;

switch  n.gateWayMode(v) {
case gwModeNAT:
    // ....
case gwModeNATUnprot:
    // ...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we end up adding a routed-unprotected mode, this code will want to know whether the mode is nat-unprotected or routed-unprotected ... it could explicitly check for the mode being any one of the ones it's interested in - but I think collecting the logic into one place is better. Less danger of missing-a-bit when things change. But, can change it if you like?

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've unwrapped it a bit ... now there's a bridgeNetwork.gwMode() that returns a gwMode, and the caller checks unprotected() themselves.

(Similarly, could replace getNATDisabled() with gwMode(), although it'd mean acquiring the mutex an extra time to get both modes.)

// that can route directly to the container's address (by setting up a
// route via the host's address).
action := "DROP"
if n.isGwModeUnprotected(ipVersion) {
Copy link
Member

Choose a reason for hiding this comment

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

Related to my earlier pondering about locking; this takes a lock here (not really important, just .. thinking out loud I guess)


// 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 {
Copy link
Member

Choose a reason for hiding this comment

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

But interestingly (regarding my "locking" pondering); n.registerIptCleanFunc actually mutates state of the network and does not have any synchronisation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right - it's a mess ... but, not this PR's mess!

// 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: %s", err.Error())
Copy link
Member

Choose a reason for hiding this comment

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

ugh; I see a bunch more further up, and this was probably existing code, but we really shouldn't use err.Error() for these, and either wrap the error (if we need to be able to access the wrapped one) or just format;

Suggested change
return fmt.Errorf("failed to add default-drop rule: %s", err.Error())
return fmt.Errorf("failed to add default-drop rule: %w", err)

or (if we want to mask the underlying error)

Suggested change
return fmt.Errorf("failed to add default-drop rule: %s", err.Error())
return fmt.Errorf("failed to add default-drop rule: %v", err)

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 guess it was a copy and paste of an existing block - I'll change it.

Same as "nat" mode, there's masquerading and port mapping from the
host - but no port/protocol filtering for direct access to the
container's address from remote hosts.

This is the old default behaviour for IPv4 when the filter-FORWARD
chain's default policy was "ACCEPT" (the daemon would only set it
to "DROP" when it set sysctl "ip_forward" itself, but it didn't set
up DROP rules for unpublished ports).

Now, port filtering doesn't depend on the filter-FORWARD policy. So,
this mode is added as a way to restore the old/surprising/insecure
behaviour for anyone who's depending on it. Networks will need to
be re-created with this new gateway mode.

Signed-off-by: Rob Murray <[email protected]>
Copy link
Member

@laurazard laurazard left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

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

LGTM

@thaJeztah thaJeztah merged commit 24fd633 into moby:master Nov 29, 2024
140 checks passed
@robmry robmry deleted the nat-unprotected branch December 3, 2024 11:12
aevesdocker pushed a commit to docker/docs that referenced this pull request Feb 20, 2025
## Description

Updates for moby 28.0 networking.

## Related issues or tickets

Series of commits ...
- Fix description of 'inhibit_ipv4'
- Not changed in moby 28.0, updated to clarify difference from (new)
IPv6-only networks.
- Updates to default bridge address config
  - moby/moby#48319
- Describe IPv6-only network config
  - moby/moby#48271
  - docker/cli#5599
- Update description of gateway modes
  - moby/moby#48594
  - moby/moby#48596
  - moby/moby#48597
- Describe gateway selection in the networking overview.
  - docker/cli#5664
- Describe gateway mode `isolated`
  - moby/moby#49262

## Reviews

<!-- Notes for reviewers here -->
<!-- List applicable reviews (optionally @tag reviewers) -->

- [ ] Technical review
- [ ] Editorial review
- [ ] Product review

---------

Signed-off-by: Rob Murray <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/networking/d/bridge Networking area/networking/firewalling Networking area/networking/portmapping Networking area/networking Networking impact/changelog impact/documentation kind/enhancement Enhancements are not bugs or new features but can improve usability or performance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants