首页
/ Moby 桥接网络 nat-unprotected 网关模式:nftables 端口发布规则的完整解析

Moby 桥接网络 nat-unprotected 网关模式:nftables 端口发布规则的完整解析

2026-09-06 14:55:56作者:宣聪麟

本篇技术指南基于 Moby 仓库中 nftables 文档模板 usernet-portmap-natunprot.md 展开,深入讲解桥接网络 gateway_mode_ipv4=nat-unprotected 场景下的 nftables 规则布局:如何复现该场景、完整的 ip docker-bridges 规则表长什么样、与标准 NAT 模式相比 filter-forward-in 链和 raw-PREROUTING 链发生了哪些变化,以及这些变化在 firewaller/nftabler 源码中的实现依据。读完后你可以独立读懂 Docker 在该模式下生成的全部 nftables 规则,并理解“非保护 NAT”的确切语义。

场景与前提

nat-unprotected 是 Moby 桥接网络驱动支持的一种网关模式(gateway mode)。在 bridge_linux.go 中定义了全部合法取值:

const (
	gwModeDefault   gwMode = ""
	gwModeNAT       gwMode = "nat"
	gwModeNATUnprot gwMode = "nat-unprotected"
	gwModeRouted    gwMode = "routed"
	gwModeIsolated  gwMode = "isolated"
)

创建网络时通过 com.docker.network.bridge.gateway_mode_ipv4 选项指定,newGwMode 负责解析并在遇到未知值时报 unknown gateway mode 错误。

按仓库文档模板描述,该场景等价于:以禁用用户态代理(userland proxy)的方式运行 dockerd,然后创建一个 nat-unprotected 网络并在其上运行带端口映射的容器:

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

对应参数说明:

  • -o com.docker.network.bridge.name=bridge1:指定网桥设备名为 bridge1,便于在 nftables 中按接口名定位规则;
  • -o com.docker.network.bridge.gateway_mode_ipv4=nat-unprotected:IPv4 网关模式设为“非保护 NAT”;
  • --subnet 192.0.2.0/24 --gateway 192.0.2.1:TEST-NET-1 文档网段,网关为 .1
  • -p 8080:80:发布宿主机 8080 端口到容器 80 端口。

需要注意的前提(来自 index.md 与生成测试 nftablesdoc_linux_test.go):

  1. 这套 nftables 规则仅面向开发用途,其结构在版本间会变化,不是稳定接口;
  2. 生成测试要求宿主机防火墙后端为 nftables、未运行 firewalld、且非 rootless;
  3. Docker 每次启动时会重建规则表(tables are re-created each time Docker starts);
  4. IPv6 规则与 IPv4 模式相同,只是位于 ip6 docker-bridges 表,因此文档只展示 IPv4(ip docker-bridges)。

完整 nftables 规则表

该文档模板通过 {{index . "Ruleset4"}} 等占位符注入真实捕获的规则。由 TestBridgeNftablesDoc 生成的 usernet-portmap-natunprot.md 中,完整的 table ip docker-bridges 如下(容器 c1 已获取 192.0.2.2-p 8080:80 生效):

table ip docker-bridges {
	map filter-forward-in-jumps {
		type ifname : verdict
		elements = { "docker0" : jump filter-forward-in__docker0,
		             "bridge1" : jump filter-forward-in__bridge1 }
	}

	map filter-forward-out-jumps {
		type ifname : verdict
		elements = { "docker0" : jump filter-forward-out__docker0,
		             "bridge1" : jump filter-forward-out__bridge1 }
	}

	map nat-postrouting-in-jumps {
		type ifname : verdict
		elements = { "docker0" : jump nat-postrouting-in__docker0,
		             "bridge1" : jump nat-postrouting-in__bridge1 }
	}

	map nat-postrouting-out-jumps {
		type ifname : verdict
		elements = { "docker0" : jump nat-postrouting-out__docker0,
		             "bridge1" : jump nat-postrouting-out__bridge1 }
	}

	chain filter-FORWARD {
		type filter hook forward priority filter; policy accept;
		oifname vmap @filter-forward-in-jumps
		iifname vmap @filter-forward-out-jumps
	}

	chain nat-OUTPUT {
		type nat hook output priority dstnat; policy accept;
		ip daddr != 127.0.0.0/8 fib daddr type local counter jump nat-prerouting-and-output
	}

	chain nat-POSTROUTING {
		type nat hook postrouting priority srcnat; policy accept;
		iifname vmap @nat-postrouting-out-jumps
		oifname vmap @nat-postrouting-in-jumps
	}

	chain nat-PREROUTING {
		type nat hook prerouting priority dstnat; policy accept;
		fib daddr type local counter jump nat-prerouting-and-output
	}

	chain nat-prerouting-and-output {
		iifname != "bridge1" tcp dport 8080 counter dnat to 192.0.2.2:80 comment "DNAT"
	}

	chain raw-PREROUTING {
		type filter hook prerouting priority raw; policy accept;
	}

	chain filter-forward-in__docker0 {
		ct state established,related counter accept
		iifname "docker0" counter accept comment "ICC"
		counter drop comment "UNPUBLISHED PORT DROP"
	}

	chain filter-forward-out__docker0 {
		ct state established,related counter accept
		counter accept comment "OUTGOING"
	}

	chain nat-postrouting-in__docker0 {
	}

	chain nat-postrouting-out__docker0 {
		oifname != "docker0" ip saddr 172.17.0.0/16 counter masquerade comment "MASQUERADE"
	}

	chain filter-forward-in__bridge1 {
		ct state established,related counter accept
		iifname "bridge1" counter accept comment "ICC"
		counter accept comment "UNPROTECTED"
	}

	chain filter-forward-out__bridge1 {
		ct state established,related counter accept
		counter accept comment "OUTGOING"
	}

	chain nat-postrouting-in__bridge1 {
	}

	chain nat-postrouting-out__bridge1 {
		oifname != "bridge1" ip saddr 192.0.2.0/24 counter masquerade comment "MASQUERADE"
	}
}

几个值得注意的结构点:

  • 四个 ifname : verdict 类型的 vmap(virtual map)是这套规则的分发机制:filter-FORWARDnat-POSTROUTING 等钩子链只做 vmap 查表跳转,把流量分发到每个网桥的专属链(__docker0__bridge1),实现按接口隔离;
  • nat-prerouting-and-output 链中的 dnat to 192.0.2.2:80 comment "DNAT" 就是 -p 8080:80 的落地规则,iifname != "bridge1" 条件排除了来自网桥本接口方向的包;
  • nat-postrouting-out__bridge1masquerade 规则把离开 bridge1 且源地址在 192.0.2.0/24 的包做地址伪装,支撑容器出网;
  • Docker 不使用 filter-INPUT 钩子:来自宿主机物理网络或宿主机自身的包会被路由进桥接网络,命中 filter-FORWARD(见 index.md 的说明)。

与标准 NAT 模式的关键差异

文档模板的核心论点是:与标准 nat 模式网络 相比,大部分规则相同,但有两处关键不同,它们共同定义了 nat-unprotected 的语义——不做未发布端口的过滤,也不阻止按容器 IP 直连

差异一:filter-forward-in 链没有按端口过滤

对比两张表:

标准 nat 模式(默认桥 docker0)的入方向链末尾是丢弃规则:

chain filter-forward-in__docker0 {
	ct state established,related counter accept
	iifname "docker0" counter accept comment "ICC"
	counter drop comment "UNPUBLISHED PORT DROP"
}

nat-unprotected 网络的入方向链则替换为放行规则:

chain filter-forward-in__bridge1 {
	ct state established,related counter accept
	iifname "bridge1" counter accept comment "ICC"
	counter accept comment "UNPROTECTED"
}

也就是说,filter-forward-in__bridge1 链没有针对发布端口的逐端口放行规则,而是接受任意端口的入方向包。这一点在源码 nftabler/network.go 中一目了然——网络配置里的 Unprotected 标志决定最终落入哪个分支:

// Incoming traffic
if conf.Unprotected {
    tm.Create(nftables.Rule{
        Chain: fwdInChain,
        Group: fwdInFinalRuleGroup,
        Rule:  []string{`counter accept comment "UNPROTECTED"`},
    })
} else {
    tm.Create(nftables.Rule{
        Chain: fwdInChain,
        Group: fwdInFinalRuleGroup,
        Rule:  []string{`counter drop comment "UNPUBLISHED PORT DROP"`},
    })
}

Unprotected 的字段定义在 firewaller.go

// NetworkConfigFam contains network configuration for a single address family.
type NetworkConfigFam struct {
	HostIP netip.Addr
	Prefix netip.Prefix
	Routed bool
	// Unprotected is true if no rules to filter unpublished ports or direct access from
	// any remote host are required.
	Unprotected bool
}

模板文档特别注明了这条 accept 的实际用途:当 filter-FORWARD 链的默认策略是 "drop" 时,如果没有这条放行规则,转发流量会被整体丢弃;UNPROTECTED 规则承担了“兜底放行”的角色。

差异二:raw-PREROUTING 链中没有 "DROP DIRECT ACCESS" 规则

上面的生成文档里,raw-PREROUTING 链几乎是空的:

chain raw-PREROUTING {
	type filter hook prerouting priority raw; policy accept;
}

而在标准 nat 模式下,每当一个端点(容器)加入网络时,Docker 会在这里追加一条按容器 IP 丢弃外部直连包的规则。这条规则的创建逻辑在 nftabler/endpoint.go

func (n *network) filterDirectAccess(updater func(nftables.Obj), fam nftables.Family, conf firewaller.NetworkConfigFam, epIP netip.Addr) {
	if n.config.Internal || conf.Unprotected || conf.Routed || n.fw.config.AllowDirectRouting {
		return
	}
	ifNames := strings.Join(n.config.TrustedHostInterfaces, ", ")
	updater(nftables.Rule{
		Chain: rawPreroutingChain,
		Group: rawPreroutingPortsRuleGroup,
		Rule: []string{
			string(fam), "daddr", epIP.String(),
			"iifname != {", n.config.IfName, ",", ifNames, `} counter drop comment "DROP DIRECT ACCESS"`,
		},
	})
}

filterDirectAccessconf.Unprotected 为真时直接返回(no-op),因此 nat-unprotected 网络下永远不会写入 DROP DIRECT ACCESS 规则。其效果就是模板文档所写的:In chain raw-PREROUTING, there's no "DROP DIRECT ACCESS" rule, so container can be accessed from outside the host——外部主机可以直接按容器的 IP(如 192.0.2.2)访问该容器,而不必经由 -p 发布的端口做 DNAT。

保持不变的规则:dnat 与 masquerade

文档模板最后强调:dnatmasquerade 规则仍然存在,而且如果启用了用户态代理(userland proxy),它依然会被启动。这在规则表里可以直接验证:

  • 端口发布的 DNAT:nat-prerouting-and-output 链中的 iifname != "bridge1" tcp dport 8080 counter dnat to 192.0.2.2:80 comment "DNAT"
  • 出网伪装:nat-postrouting-out__bridge1 链中的 oifname != "bridge1" ip saddr 192.0.2.0/24 counter masquerade comment "MASQUERADE",其生成逻辑在 nftabler/network.go(未指定 HostIP 时选择 masquerade,指定了则退化为固定地址的 SNAT)。

逐端口的规则(端口发布规则、以及 nat 模式下的按端口转发放行)由 nftabler/port.go 中的 setPerPortRules 处理,其中同样接收 n.config.Config4.Unprotected 作为参数——在 nat 模式下它会为发布端口写入 filter 链中的放行条目,而 nat-unprotected 模式下这一层过滤被网络级的 UNPROTECTED 放行所取代。iptables 后端的语义与之对应,见 iptabler/endpoint.goiptabler/port.go(注释明确写道 “gw_mode=nat-unprotected means there's minimal security for NATed ports”),对应的规则文档在 iptablesdoc/generated/usernet-portmap-natunprot.md

可以把 nat-unprotected 理解为“保留了 NAT 的连通性、移除了 NAT 的隔离性”:地址转换照常工作,但未发布端口不再被屏蔽、容器 IP 不再对外不可达。

这份文档是如何生成和校验的

上述规则表并非手写,而是由集成测试 TestBridgeNftablesDoc 自动捕获生成的,理解其流程有助于确认规则内容的可信度:

  1. 场景声明index 切片中的 usernet-portmap-natunprot.md 条目 声明了 gwMode: "nat-unprotected"、网络名 bridge1、容器 c1 的端口映射 80/tcp -> 8080,网段取自 docNetworks/docGateways(192.0.2.0/24);
  2. 隔离执行:每个 section 在自己的网络命名空间里启动一个真实的 dockerd(runTestNet),创建网络、运行容器;
  3. 规则捕获runNftables 执行 nft -s list table ip docker-bridges,把输出按 map/chain 切块存入模板数据(Ruleset4chain filter-forward-in__bridge1 等键),并将 priority -100 规范化为 dstnat 字样;
  4. 模板渲染generatetext/template 渲染 templates/ 下与 section 同名的模板文件(即本篇基于的 templates/usernet-portmap-natunprot.md);
  5. 黄金文件比对:渲染结果写入 bundles 目录,并与 generated/ 中的参考文档做 golden 对比,规则不一致则测试失败;需要更新时先检查 diff、修改对应模板描述,再用 TESTFLAGS='-update' 重新生成(见该测试文件的包注释)。

在单元测试层面,nftabler_test.go 遍历 natnat-unprotectedrouted 三种网关模式,将 Unprotected: gwmode == "nat-unprotected" 传入 firewaller 配置,并与 testdata 中的 golden 文件 逐条比对,其中每个 gwm=nat-unprotected 的 golden 文件都包含 counter accept comment "UNPROTECTED" 行——与上文规则表相互印证。

小结

  • nat-unprotected 通过 com.docker.network.bridge.gateway_mode_ipv4 选项指定,在源码中解析为 gwModeNATUnprot,并映射为 firewaller 的 Unprotected 标志;
  • 该模式下 nftables 规则与标准 NAT 模式几乎一致,唯二区别是:入方向链以 counter accept comment "UNPROTECTED" 取代逐端口放行与 UNPUBLISHED PORT DROP,以及 raw-PREROUTING 中不写入 DROP DIRECT ACCESS,从而允许外部直接按容器 IP 访问;
  • DNAT(端口发布)与 masquerade(出网伪装)规则不受影响,用户态代理的启用行为也不变;
  • 由于未发布端口不再被过滤、容器地址对外可达,这是一种“最小安全”的 NAT 模式,应在明确接受这一风险面后再用于生产网络;
  • 本文所述规则可通过 generated/usernet-portmap-natunprot.md 复核,或参照 nftablesdoc_linux_test.go 描述的 TESTFLAGS='-update' 流程重新生成。
登录后查看全文
热门项目推荐
相关项目推荐