Skip to content

fix(cubenet): resolve direct on-link neighbors via ARP - #1321

Open
jay3cx wants to merge 4 commits into
TencentCloud:masterfrom
jay3cx:fix/issue-1209-direct-egress-onlink
Open

fix(cubenet): resolve direct on-link neighbors via ARP#1321
jay3cx wants to merge 4 commits into
TencentCloud:masterfrom
jay3cx:fix/issue-1209-direct-egress-onlink

Conversation

@jay3cx

@jay3cx jay3cx commented Aug 11, 2026

Copy link
Copy Markdown

Motivation

In direct egress mode, CubeVS sends outbound traffic to the node gateway MAC.

This works for off-link destinations, but on-link traffic then depends on the gateway forwarding the packet back through the same interface. On networks without that hairpin behavior, same-subnet destinations are unreachable.

What Changed

  • Pass the node's primary IPv4 mask to CubeVS to identify on-link destinations in direct mode.
  • Add an LRU IP-to-MAC map for direct on-link neighbors.
  • Try bpf_fib_lookup first. If the kernel has no neighbor, replace the packet with an ARP request and keep a pending map entry.
  • Learn ARP replies only for IPs already present in the map.
  • Once the MAC is learned, rewrite later packets with that MAC and send them out the node NIC.
  • Keep the existing gateway-MAC path for off-link direct egress and route-aware egress.
  • Keep the from_world dispatch path inlined so Linux 5.4 can load the DNS tail call.

A cache miss consumes the triggering packet (it becomes the ARP request). A later revalidation probe does the same. No userspace resolver or host iptables/sysctl setup is required.

Validation

  • Ran go test -count=1 ./... and go vet ./... in CubeNet/cubevs.
  • Ran go test -count=1 ./network/runtime/systemnet ./network/runtime and go vet ./network/runtime/... in Cubelet.
  • Tested on Ubuntu 20.04.6 with Linux 5.4.0-216-generic on arm64:
    • Generated the CubeVS BPF objects with Go 1.24.8 and clang 14.
    • Loaded all generated BPF programs successfully.
    • Attached from_cube and from_world and confirmed that both were JIT-compiled.
    • Verified the datapath in isolated network namespaces using veth pairs:
      • On a cold neighbor miss, the first ICMP packet was converted into an ARP request.
      • The ARP reply populated the neighbor map, and the second ICMP packet succeeded.
      • On-link UDP and TCP traffic passed.
      • Packet capture showed on-link traffic using the peer MAC.
      • Off-link traffic continued to use the configured gateway MAC.

Scope

This change only affects IPv4 on-link traffic in direct egress mode.

Off-link direct egress and route-aware egress keep their existing behavior.

Fixes #1209

Signed-off-by: jay3cx 137191587@qq.com

Comment thread CubeNet/cubevs/util_test.go Outdated
Comment thread CubeNet/cubevs/util.go Outdated
Comment thread CubeNet/src/mvmtap.bpf.c Outdated
Comment thread Cubelet/network/runtime/systemnet/cube_dev.go Outdated
@cubesandboxbot

cubesandboxbot Bot commented Aug 11, 2026

Copy link
Copy Markdown

AI-generated review (not human-approved)

Overall. This is a well-structured fix for #1209. In direct egress mode the datapath previously sent everything to the node-gateway MAC, which breaks on-link traffic on networks without hairpin forwarding. The PR adds a direct_neigh LRU map, tries bpf_fib_lookup first, converts the triggering packet into an ARP request on a cache miss, and learns ARP replies only for IPs already present in the map. Byte-order handling is consistent with the existing ipToUint32 convention, the nodenic_netmask plumbing (Go → BPF constant) is correct, and the __always_inline conversion of do_{tcp,udp,icmp}_nat in nodenic.bpf.c is a legitimate Linux 5.4 fix (programs mixing tail calls with subprog calls are rejected pre-5.10). The design tradeoff — "a cache miss consumes the triggering packet" — is documented and reasonable.

The main concerns are ordering and a few correctness edge cases, listed below. None appear to be release-blocking, but several deserve a look before merge.


Findings

1. ARP resolution now happens before the NAT/policy checks (medium). prepare_egress_l2() is called in from_cube before do_tcp_nat / do_udp_nat_inline / do_icmp_nat, which is where egress-policy deny decisions and RST-on-deny are made. Consequences:

  • A policy-denied on-link destination still emits an ARP request on the wire (an ARP request is generated for traffic that would be dropped). The base code never touched the wire for denied flows.
  • The first packet of every flow to a new (or expired) on-link neighbor is consumed as an ARP probe. For TCP, the SYN that would previously have drawn an immediate RST-on-deny is instead converted into an ARP request; the RST only appears after the neighbor is learned (~1s later). For a single-shot UDP datagram, the datagram is simply lost.

Worth confirming this is acceptable for the policy model. If not, the neighbor lookup could be deferred until after the session is created (accepting that the L2 rewrite then has to be re-done post-NAT).

2. bpf_fib_lookup + BPF_FIB_LOOKUP_DIRECT on Linux 5.4 (medium, verify). The PR explicitly targets Linux 5.4, but BPF_FIB_LOOKUP_DIRECT was only introduced in a later kernel (5.10). On 5.4 the helper almost certainly returns an error for the unknown flag, so the fib fast path silently never fires and every on-link packet goes through the neighbor-map / ARP path. The ARP fallback still works (and was validated on 5.4), so this is not a functional break — but the "try bpf_fib_lookup first" path is dead code on the stated target, and there's a wasted helper call per on-link packet. Consider gating on kernel version or documenting the fallback. Also note BPF_FIB_LOOKUP_OUTPUT may trigger the kernel's own neighbor resolution, producing duplicate ARP requests on the segment.

3. ARP-reply learning doesn't verify the reply's target IP (low/medium). learn_direct_neighbor validates protocol/opcode and Ethernet-sender vs ARP-sender MAC consistency, but never checks ar_tip == nodenic_ip. The BPF_EXIST guard ("learn only IPs already in the map") limits exposure to IPs that were already probed, but within that set any self-consistent ARP reply (e.g. a spoofed reply from an on-link attacker who saw the probe) populates the cache. A cheap hardening step is to require packet->arp.ar_tip == nodenic_ip.

4. Subnet-directed broadcast addresses are classified as on-link (low). (daddr & mask) == (ip & mask) is true for the subnet-directed broadcast (e.g. x.x.x.255 on a /24). If bpf_fib_lookup doesn't resolve it to the all-ones MAC, the packet is converted into an ARP request for the broadcast address (which nobody answers) and consumed. Consider excluding broadcast/multicast destinations from the on-link/ARP path so existing broadcast semantics are preserved.

5. Unrelated change bundled in (low). The removal of the two os.Remove pin-cleanup calls in Init (tungrp_to_tuns, dns_query_track) in miscs.go is unrelated to the on-link feature and changes restart semantics: a stale pinned dns_query_track map is now reused across restarts. Probably benign (entries are TTL'd), but it would be cleaner as a separate PR or with a comment explaining why the cleanup was removed.

6. ARP-conversion edge cases on GSO / CHECKSUM_PARTIAL skbs (low). direct_egress_arp_request relies on bpf_skb_change_tail/bpf_skb_change_head and leaves a CHECKSUM_PARTIAL slot pointing into the zeroed padding region so the NIC's L4 checksum write lands outside the ARP header. This is fragile but works for the common case (probes are triggered by small first packets / SYN); a GSO skb hitting the revalidation probe path could be dropped (handled by TC_ACT_SHOT) or left with inconsistent segmentation state.


Positive notes. The direct_neigh map value layout (6-byte MAC + deadline) is sound; the in-place deadline touch followed by a re-lookup correctly reconciles the concurrent-learn race; the BPF_NOEXIST insert before sending the ARP request prevents duplicate probes; the nodenic_ifindex guard in from_world correctly scopes learning to the node NIC (not the cube-router); and the mask/address comparison is consistent with the little-endian __be32 convention used throughout the datapath.

@jay3cx

jay3cx commented Aug 11, 2026

Copy link
Copy Markdown
Author

Review: fix(cubenet): route direct on-link egress via host (PR #1321)

评审:修复(cubenet):通过主机路由直接链路出口(PR #1321
AI-generated review — not a human approval.AI 生成的评审——并非人工审批。

Overview

The PR fixes #1209: in direct-egress mode CubeVS always rewrote the destination MAC to the node gateway MAC, which breaks same-subnet traffic when the gateway doesn't hairpin. The approach:该 PR 修复了 #1209 :在直接出口模式下,CubeVS 总是将目的 MAC 重写为节点网关 MAC,当网关不支持 hairpin 时,这会破坏同子网流量。解决方案:

  • Propagates the primary node IPv4 mask into CubeVS (nodenic_netmask) and detects on-link destinations in BPF.将节点主 IPv4 掩码传播到 CubeVS( nodenic_netmask ),并在 BPF 中检测链路直连目的地。
  • For on-link traffic, preserves the sandbox-side L2 pair, keeps the SNAT, and redirects into cube-dev ingress so the host routing/neighbor tables resolve the real destination MAC.对于直连流量,保留沙箱侧的 L2 地址对,维持 SNAT,并将其重定向至 cube-dev 入口,以便主机路由/邻居表解析出真实的目的 MAC 地址。
  • Preserves the gateway-MAC fast path for off-link traffic.对于非直连流量,保留网关 MAC 快速路径。
  • Configures cube-dev forwarding / accept_local / loose rp_filter and a narrow idempotent FORWARD iptables rule.配置 cube-dev 转发、 accept_local 、宽松的 rp_filter 以及一条窄范围的幂等 FORWARD iptables 规则。

The design is sound and well-integrated: the "SNAT then inject into cube-dev ingress" mechanism already exists in the codebase (L7-proxy path in from_cube, node-IP path), the per-protocol L2 preservation is correctly gated, the dns_finish rewrite to a single goto is behavior-preserving, and the Go-side defensive copies and test seams are good. The iptables helper (runIptablesEnsure) is genuinely idempotent.该设计合理且集成良好:“先 SNAT 再注入 cube-dev 入口”的机制已在代码库中存在( from_cube 中的 L7 代理路径、节点 IP 路径),按协议保留 L2 的操作正确设置了门控, dns_finish 重写为单个 goto 保持了行为不变,Go 侧的防御性拷贝和测试接缝也做得很好。iptables 辅助函数( runIptablesEnsure )确实具有幂等性。

I found one confirmed defect (a failing unit test) plus two areas that should be addressed before merge.我发现了一个已确认的缺陷(一个失败的单元测试),另外还有两个问题需要在合并前处理。

Findings

1. HIGH — TestIPMaskToUint32RejectsNonIPv4Mask fails: non-contiguous masks are not rejected

  1. 高优先级 — TestIPMaskToUint32RejectsNonIPv4Mask 失败:未拒绝非连续掩码
    CubeNet/cubevs/util.go:35ipMaskToUint32 only validates len(mask) == 4 and that mask.Size() reports 32 bits. net.IPMask.Size() always returns bits = len(mask)*8, so any 4-byte mask passes the second check. The new test TestIPMaskToUint32RejectsNonIPv4Mask (CubeNet/cubevs/util_test.go:37) asserts that net.IPMask{255, 0, 255, 0} is rejected, but the implementation accepts it (returning 0x00ff00ff) and the test fails with "ipMaskToUint32 accepted a non-contiguous IPv4 mask". CubeNet/cubevs/util.go:35ipMaskToUint32 仅验证 len(mask) == 4 以及 mask.Size() 报告为 32 位。 net.IPMask.Size() 始终返回 bits = len(mask)*8 ,因此任何 4 字节掩码都能通过第二项检查。新测试 TestIPMaskToUint32RejectsNonIPv4MaskCubeNet/cubevs/util_test.go:37 )断言 net.IPMask{255, 0, 255, 0} 应被拒绝,但实现却接受了它(返回 0x00ff00ff ),导致测试以 "ipMaskToUint32 accepted a non-contiguous IPv4 mask" 失败。

This contradicts the PR's validation claim of go test -count=1 ./... in CubeNet/cubevs.这与 PR 中在 CubeNet/cubevs 中声称的 go test -count=1 ./... 验证逻辑相矛盾。

The gap also matters beyond the test: a non-contiguous mask would silently produce a wrong on-link comparison in direct_egress_is_onlink while the error message claims it was validated. Fix: validate contiguity (e.g., once a non-0xff byte is seen, every following byte must be 0), or drop the second test assertion.测试之外,这个差距也很重要:不连续的掩码会在 direct_egress_is_onlink 中静默产生错误的直连比较,而错误信息却声称已验证。修复方法:验证连续性(例如,一旦看到非 0xff 字节,后续每个字节都必须是 0 ),或者删除第二个测试断言。

2. MEDIUM — New hard runtime dependency on iptables + /proc/sys writes in direct mode

  1. 中等 — 在直接模式下,对 iptables/proc/sys 的写入新增了硬性运行时依赖
    Cubelet/network/runtime/systemnet/cube_dev.go:40ConfigureCubeDevHostRouting is now called unconditionally in the direct-egress branch of initCubeVS. Previously direct mode only ran CleanupCubeRouter and made no iptables/sysctl calls. Now a missing iptables binary or a read-only /proc/sys/net/ipv4/conf/cube-dev/* fails initCubeVS, which fails newProductionControllerDeps, which fails Cubelet startup. Cubelet/network/runtime/systemnet/cube_dev.go:40ConfigureCubeDevHostRouting 现在在 initCubeVS 的直接出口分支中被无条件调用。此前,直接模式仅运行 CleanupCubeRouter ,且不进行任何 iptables /sysctl 调用。现在,缺少 iptables 二进制文件或只读的 /proc/sys/net/ipv4/conf/cube-dev/* 会导致 initCubeVS 失败,进而导致 newProductionControllerDeps 失败,最终导致 Cubelet 启动失败。

Also, the FORWARD rule and the three sysctls are never cleaned up when the node switches to route-aware mode or shuts down. The rule is narrow (-i cube-dev -o <eth> -s <nodeIP>/32 -d <onlinkNet>) so the residue is low-risk, but it should be documented or paired with a teardown.此外,当节点切换到路由感知模式或关闭时, FORWARD 规则和三个 sysctl 设置从未被清理。该规则范围较窄( -i cube-dev -o <eth> -s <nodeIP>/32 -d <onlinkNet> ),因此残留风险较低,但应记录在案或配合拆除机制。

3. MEDIUM — Linux 5.4 verifier budget risk on the dns_finish path

  1. 中 — Linux 5.4 验证器预算在 dns_finish 路径上的风险
    CubeNet/src/mvmtap.bpf.c:699 — the PR adds inlined logic (direct_egress_is_onlink, redirect_egress, plus a new bpf_skb_load_bytes) to finish_udp_nat, which is inlined into dns_finish. The codebase contains extensive comments explaining that dns_finish already sits close to the 5.4 1M-instruction verifier limit (that's exactly why do_udp_nat was made __noinline). from_cube likewise gains three inlined redirect_egress call sites and is constrained to have no bpf-to-bpf calls. CubeNet/src/mvmtap.bpf.c:699 — 该 PR 向 finish_udp_nat 添加了内联逻辑( direct_egress_is_onlinkredirect_egress ,以及新的 bpf_skb_load_bytes ),而 finish_udp_nat 又被内联到 dns_finish 中。代码库中包含大量注释,说明 dns_finish 已经接近 5.4 的 100 万条指令验证器限制(这正是 do_udp_nat 被设为 __noinline 的原因)。 from_cube 同样增加了三个内联的 redirect_egress 调用点,并且被限制为不允许 bpf 到 bpf 的调用。

The PR was only validated on Linux 6.6 and explicitly notes 5.4 was not tested. Please run the verifier (or at least veristat, per CubeNet/cubevs/Makefile) on a 5.4 kernel/compiler target before merge.该 PR 仅在 Linux 6.6 上验证过,并明确说明未在 5.4 上测试。请在合并前,在 5.4 内核/编译器目标上运行验证器(或至少按照 CubeNet/cubevs/Makefile 运行 veristat )。

4. LOW — NodeIPMask is now a hard requirement of cubevs.Init

  1. 低 — NodeIPMask 现在是 cubevs.Init 的硬性要求。
    CubeNet/cubevs/miscs.goipMaskToUint32(params.NodeIPMask) errors are joined into the result before the nil-variable guard, and rewriteConstants is invoked for loadLocalgw/loadNodenic too. A nil NodeIPMask therefore fails Init for all objects, even though localgw/nodenic never reference nodenic_netmask. The only production caller (controller.go) sets it, so this is latent — but any other embedder of the cubevs package will now fail at startup with an opaque "invalid IPv4 mask length" error. Consider only failing when the spec actually contains globalNameNodeNetmask. CubeNet/cubevs/miscs.goipMaskToUint32(params.NodeIPMask) 的错误会在 nil 变量检查之前合并到结果中,并且 rewriteConstants 也会对 loadLocalgw / loadNodenic 调用。因此,即使 localgw / nodenic 从未引用 nodenic_netmask ,nil 的 NodeIPMask 也会导致所有对象在 Init 处失败。唯一的生成环境调用方( controller.go )会设置它,所以这目前是潜在问题——但任何其他嵌入 cubevs 包的使用者现在都会在启动时因不透明的“无效 IPv4 掩码长度”错误而失败。建议仅在规范实际包含 globalNameNodeNetmask 时才触发失败。

5. LOW — On-link detection is subnet-based, not route-based

  1. 低优先级——在线检测基于子网而非路由
    direct_egress_is_onlink approximates on-link as "shares the node IP's subnet." This is a reasonable simplification for the reported bug, but destinations reachable through a directly-connected interface on a different subnet, or multiple subnets on the node, are misclassified as off-link and keep the gateway-MAC fast path (old behavior). Worth a one-line comment documenting the approximation so future readers don't treat it as authoritative. direct_egress_is_onlink 将在线近似为“与节点 IP 共享子网”。对于所报告的问题,这是一个合理的简化,但通过不同子网上的直连接口可达的目的地,或节点上存在多个子网时,会被错误分类为离线,并保留网关 MAC 快速路径(旧行为)。值得添加一行注释说明这一近似,以免未来读者将其视为权威定义。

Minor / nits

  • CubeNet/src/cubevs.h:101 comment says nodenic_netmask is "network byte order", while ipMaskToUint32 and ipToUint32 produce the host-little-endian layout of the network-ordered bytes. The Go comment in util.go is accurate; the .h comment is misleading. CubeNet/src/cubevs.h:101 的注释称 nodenic_netmask 为“网络字节序”,而 ipMaskToUint32ipToUint32 生成的是网络字节序字节的主机小端布局。 util.go 中的 Go 注释是准确的; .h 的注释具有误导性。
  • ConfigureCubeDevHostRouting validates the mask length/bits but not contiguity — same gap as Finding 1; masks sourced from netlink are always contiguous, so this is cosmetic. ConfigureCubeDevHostRouting 验证了掩码长度/位数,但不验证连续性——与发现 1 存在相同的缺口;来自 netlink 的掩码始终是连续的,因此这只是外观上的问题。

Verified correct

  • Byte-order math for ipMaskToUint32 matches the existing ipToUint32 convention, and the BPF (daddr & mask) == (nodenic_ip & mask) comparison is consistent. ipMaskToUint32 的字节序计算与现有的 ipToUint32 约定一致,且 BPF (daddr & mask) == (nodenic_ip & mask) 比较是一致的。
  • NAT only rewrites saddr, so using the pre-NAT daddr in from_cube and the bpf_skb_load_bytes-loaded daddr after do_udp_nat in dns_finish is safe.NAT 仅重写 saddr,因此在 from_cube 中使用 NAT 前的 daddr ,并在 dns_finish 中使用 do_udp_nat 后加载的 bpf_skb_load_bytes daddr 是安全的。
  • The dns_finish goto refactor is behavior-preserving. dns_finish goto 的重构保持了行为不变。
  • The FORWARD rule is narrow and idempotent; accept_local + rp_filter=2 on the ingress device are the right knobs for this path.FORWARD 规则范围狭窄且具有幂等性;入口设备上的 accept_local + rp_filter=2 正是该路径的正确控制点。

Review: fix(cubenet): route direct on-link egress via host (PR #1321)

评审:修复(cubenet):通过主机路由直接链路出口(PR #1321
AI-generated review — not a human approval.AI 生成的评审——并非人工审批。

Overview

The PR fixes #1209: in direct-egress mode CubeVS always rewrote the destination MAC to the node gateway MAC, which breaks same-subnet traffic when the gateway doesn't hairpin. The approach:该 PR 修复了 #1209 :在直接出口模式下,CubeVS 总是将目的 MAC 重写为节点网关 MAC,当网关不支持 hairpin 时,这会破坏同子网流量。解决方案:

  • Propagates the primary node IPv4 mask into CubeVS (nodenic_netmask) and detects on-link destinations in BPF.将节点主 IPv4 掩码传播到 CubeVS( nodenic_netmask ),并在 BPF 中检测链路直连目的地。
  • For on-link traffic, preserves the sandbox-side L2 pair, keeps the SNAT, and redirects into cube-dev ingress so the host routing/neighbor tables resolve the real destination MAC.对于直连流量,保留沙箱侧的 L2 地址对,维持 SNAT,并将其重定向至 cube-dev 入口,以便主机路由/邻居表解析出真实的目的 MAC 地址。
  • Preserves the gateway-MAC fast path for off-link traffic.对于非直连流量,保留网关 MAC 快速路径。
  • Configures cube-dev forwarding / accept_local / loose rp_filter and a narrow idempotent FORWARD iptables rule.配置 cube-dev 转发、 accept_local 、宽松的 rp_filter 以及一条窄范围的幂等 FORWARD iptables 规则。

The design is sound and well-integrated: the "SNAT then inject into cube-dev ingress" mechanism already exists in the codebase (L7-proxy path in from_cube, node-IP path), the per-protocol L2 preservation is correctly gated, the dns_finish rewrite to a single goto is behavior-preserving, and the Go-side defensive copies and test seams are good. The iptables helper (runIptablesEnsure) is genuinely idempotent.该设计合理且集成良好:“先 SNAT 再注入 cube-dev 入口”的机制已在代码库中存在( from_cube 中的 L7 代理路径、节点 IP 路径),按协议保留 L2 的操作正确设置了门控, dns_finish 重写为单个 goto 保持了行为不变,Go 侧的防御性拷贝和测试接缝也做得很好。iptables 辅助函数( runIptablesEnsure )确实具有幂等性。

I found one confirmed defect (a failing unit test) plus two areas that should be addressed before merge.我发现了一个已确认的缺陷(一个失败的单元测试),另外还有两个问题需要在合并前处理。

Findings

1. HIGH — TestIPMaskToUint32RejectsNonIPv4Mask fails: non-contiguous masks are not rejected

  1. 高优先级 — TestIPMaskToUint32RejectsNonIPv4Mask 失败:未拒绝非连续掩码
    CubeNet/cubevs/util.go:35ipMaskToUint32 only validates len(mask) == 4 and that mask.Size() reports 32 bits. net.IPMask.Size() always returns bits = len(mask)*8, so any 4-byte mask passes the second check. The new test TestIPMaskToUint32RejectsNonIPv4Mask (CubeNet/cubevs/util_test.go:37) asserts that net.IPMask{255, 0, 255, 0} is rejected, but the implementation accepts it (returning 0x00ff00ff) and the test fails with "ipMaskToUint32 accepted a non-contiguous IPv4 mask". CubeNet/cubevs/util.go:35ipMaskToUint32 仅验证 len(mask) == 4 以及 mask.Size() 报告为 32 位。 net.IPMask.Size() 始终返回 bits = len(mask)*8 ,因此任何 4 字节掩码都能通过第二项检查。新测试 TestIPMaskToUint32RejectsNonIPv4MaskCubeNet/cubevs/util_test.go:37 )断言 net.IPMask{255, 0, 255, 0} 应被拒绝,但实现却接受了它(返回 0x00ff00ff ),导致测试以 "ipMaskToUint32 accepted a non-contiguous IPv4 mask" 失败。

This contradicts the PR's validation claim of go test -count=1 ./... in CubeNet/cubevs.这与 PR 中在 CubeNet/cubevs 中声称的 go test -count=1 ./... 验证逻辑相矛盾。

The gap also matters beyond the test: a non-contiguous mask would silently produce a wrong on-link comparison in direct_egress_is_onlink while the error message claims it was validated. Fix: validate contiguity (e.g., once a non-0xff byte is seen, every following byte must be 0), or drop the second test assertion.测试之外,这个差距也很重要:不连续的掩码会在 direct_egress_is_onlink 中静默产生错误的直连比较,而错误信息却声称已验证。修复方法:验证连续性(例如,一旦看到非 0xff 字节,后续每个字节都必须是 0 ),或者删除第二个测试断言。

2. MEDIUM — New hard runtime dependency on iptables + /proc/sys writes in direct mode

  1. 中等 — 在直接模式下,对 iptables/proc/sys 的写入新增了硬性运行时依赖
    Cubelet/network/runtime/systemnet/cube_dev.go:40ConfigureCubeDevHostRouting is now called unconditionally in the direct-egress branch of initCubeVS. Previously direct mode only ran CleanupCubeRouter and made no iptables/sysctl calls. Now a missing iptables binary or a read-only /proc/sys/net/ipv4/conf/cube-dev/* fails initCubeVS, which fails newProductionControllerDeps, which fails Cubelet startup. Cubelet/network/runtime/systemnet/cube_dev.go:40ConfigureCubeDevHostRouting 现在在 initCubeVS 的直接出口分支中被无条件调用。此前,直接模式仅运行 CleanupCubeRouter ,且不进行任何 iptables /sysctl 调用。现在,缺少 iptables 二进制文件或只读的 /proc/sys/net/ipv4/conf/cube-dev/* 会导致 initCubeVS 失败,进而导致 newProductionControllerDeps 失败,最终导致 Cubelet 启动失败。

Also, the FORWARD rule and the three sysctls are never cleaned up when the node switches to route-aware mode or shuts down. The rule is narrow (-i cube-dev -o <eth> -s <nodeIP>/32 -d <onlinkNet>) so the residue is low-risk, but it should be documented or paired with a teardown.此外,当节点切换到路由感知模式或关闭时, FORWARD 规则和三个 sysctl 设置从未被清理。该规则范围较窄( -i cube-dev -o <eth> -s <nodeIP>/32 -d <onlinkNet> ),因此残留风险较低,但应记录在案或配合拆除机制。

3. MEDIUM — Linux 5.4 verifier budget risk on the dns_finish path

  1. 中 — Linux 5.4 验证器预算在 dns_finish 路径上的风险
    CubeNet/src/mvmtap.bpf.c:699 — the PR adds inlined logic (direct_egress_is_onlink, redirect_egress, plus a new bpf_skb_load_bytes) to finish_udp_nat, which is inlined into dns_finish. The codebase contains extensive comments explaining that dns_finish already sits close to the 5.4 1M-instruction verifier limit (that's exactly why do_udp_nat was made __noinline). from_cube likewise gains three inlined redirect_egress call sites and is constrained to have no bpf-to-bpf calls. CubeNet/src/mvmtap.bpf.c:699 — 该 PR 向 finish_udp_nat 添加了内联逻辑( direct_egress_is_onlinkredirect_egress ,以及新的 bpf_skb_load_bytes ),而 finish_udp_nat 又被内联到 dns_finish 中。代码库中包含大量注释,说明 dns_finish 已经接近 5.4 的 100 万条指令验证器限制(这正是 do_udp_nat 被设为 __noinline 的原因)。 from_cube 同样增加了三个内联的 redirect_egress 调用点,并且被限制为不允许 bpf 到 bpf 的调用。

The PR was only validated on Linux 6.6 and explicitly notes 5.4 was not tested. Please run the verifier (or at least veristat, per CubeNet/cubevs/Makefile) on a 5.4 kernel/compiler target before merge.该 PR 仅在 Linux 6.6 上验证过,并明确说明未在 5.4 上测试。请在合并前,在 5.4 内核/编译器目标上运行验证器(或至少按照 CubeNet/cubevs/Makefile 运行 veristat )。

4. LOW — NodeIPMask is now a hard requirement of cubevs.Init

  1. 低 — NodeIPMask 现在是 cubevs.Init 的硬性要求。
    CubeNet/cubevs/miscs.goipMaskToUint32(params.NodeIPMask) errors are joined into the result before the nil-variable guard, and rewriteConstants is invoked for loadLocalgw/loadNodenic too. A nil NodeIPMask therefore fails Init for all objects, even though localgw/nodenic never reference nodenic_netmask. The only production caller (controller.go) sets it, so this is latent — but any other embedder of the cubevs package will now fail at startup with an opaque "invalid IPv4 mask length" error. Consider only failing when the spec actually contains globalNameNodeNetmask. CubeNet/cubevs/miscs.goipMaskToUint32(params.NodeIPMask) 的错误会在 nil 变量检查之前合并到结果中,并且 rewriteConstants 也会对 loadLocalgw / loadNodenic 调用。因此,即使 localgw / nodenic 从未引用 nodenic_netmask ,nil 的 NodeIPMask 也会导致所有对象在 Init 处失败。唯一的生成环境调用方( controller.go )会设置它,所以这目前是潜在问题——但任何其他嵌入 cubevs 包的使用者现在都会在启动时因不透明的“无效 IPv4 掩码长度”错误而失败。建议仅在规范实际包含 globalNameNodeNetmask 时才触发失败。

5. LOW — On-link detection is subnet-based, not route-based

  1. 低优先级——在线检测基于子网而非路由
    direct_egress_is_onlink approximates on-link as "shares the node IP's subnet." This is a reasonable simplification for the reported bug, but destinations reachable through a directly-connected interface on a different subnet, or multiple subnets on the node, are misclassified as off-link and keep the gateway-MAC fast path (old behavior). Worth a one-line comment documenting the approximation so future readers don't treat it as authoritative. direct_egress_is_onlink 将在线近似为“与节点 IP 共享子网”。对于所报告的问题,这是一个合理的简化,但通过不同子网上的直连接口可达的目的地,或节点上存在多个子网时,会被错误分类为离线,并保留网关 MAC 快速路径(旧行为)。值得添加一行注释说明这一近似,以免未来读者将其视为权威定义。

Minor / nits

  • CubeNet/src/cubevs.h:101 comment says nodenic_netmask is "network byte order", while ipMaskToUint32 and ipToUint32 produce the host-little-endian layout of the network-ordered bytes. The Go comment in util.go is accurate; the .h comment is misleading. CubeNet/src/cubevs.h:101 的注释称 nodenic_netmask 为“网络字节序”,而 ipMaskToUint32ipToUint32 生成的是网络字节序字节的主机小端布局。 util.go 中的 Go 注释是准确的; .h 的注释具有误导性。
  • ConfigureCubeDevHostRouting validates the mask length/bits but not contiguity — same gap as Finding 1; masks sourced from netlink are always contiguous, so this is cosmetic. ConfigureCubeDevHostRouting 验证了掩码长度/位数,但不验证连续性——与发现 1 存在相同的缺口;来自 netlink 的掩码始终是连续的,因此这只是外观上的问题。

Verified correct

  • Byte-order math for ipMaskToUint32 matches the existing ipToUint32 convention, and the BPF (daddr & mask) == (nodenic_ip & mask) comparison is consistent. ipMaskToUint32 的字节序计算与现有的 ipToUint32 约定一致,且 BPF (daddr & mask) == (nodenic_ip & mask) 比较是一致的。
  • NAT only rewrites saddr, so using the pre-NAT daddr in from_cube and the bpf_skb_load_bytes-loaded daddr after do_udp_nat in dns_finish is safe.NAT 仅重写 saddr,因此在 from_cube 中使用 NAT 前的 daddr ,并在 dns_finish 中使用 do_udp_nat 后加载的 bpf_skb_load_bytes daddr 是安全的。
  • The dns_finish goto refactor is behavior-preserving. dns_finish goto 的重构保持了行为不变。
  • The FORWARD rule is narrow and idempotent; accept_local + rp_filter=2 on the ingress device are the right knobs for this path.FORWARD 规则范围狭窄且具有幂等性;入口设备上的 accept_local + rp_filter=2 正是该路径的正确控制点。
  • NodeIPMask is now required only for the CollectionSpec that contains from_cube and references nodenic_netmask. localgw and nodenic no longer fail when NodeIPMask is absent, and a unit test covers both cases.
  • direct_egress_is_onlink now explicitly documents that “on-link” means the primary node IPv4 prefix, not every directly connected route on a multi-homed node.
  • The nodenic_netmask comment now describes the value as packet-byte layout instead of network byte order.

The CubeVS unit tests pass, and the BPF programs were also validated successfully on Linux 5.4.0-216-generic.

Comment thread CubeNet/cubevs/util_test.go Outdated
if _, err := ipMaskToUint32(net.CIDRMask(64, 128)); err == nil {
t.Fatal("ipMaskToUint32 accepted an IPv6 mask")
}
if _, err := ipMaskToUint32(net.IPMask{255, 0, 255, 0}); err == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Failing test. ipMaskToUint32 does not reject this mask. net.IPMask{255, 0, 255, 0} has length 4, so the len(mask) != net.IPv4len guard passes, and net.IPMask.Size() returns (8, 32) for it — the bits != 32 guard passes for any 4-byte mask, contiguous or not. The function returns 0x00ff00ff, nil, so err == nil here and this t.Fatal fires, making TestIPMaskToUint32RejectsNonIPv4Mask fail. Either add a real contiguity check to ipMaskToUint32 (leading 0xff bytes followed by 0x00 bytes), or drop this assertion. As written, go test ./... in CubeNet/cubevs fails, which conflicts with the PR's validation claim.

Mask: host.IPMask,
}
if err := ensureCubeDevIptablesRule(
"-t", "filter", "-A", "FORWARD",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rule placement + no cleanup. The FORWARD ACCEPT rule is appended with -A, i.e. at the end of the filter/FORWARD chain. On hosts where that chain has a default-DROP policy or an earlier terminating rule (Docker, kube-proxy, distro firewalls), the first packet of a new on-link flow is dropped before it reaches this ACCEPT, so direct on-link egress silently fails. Consider -I FORWARD 1 (insert at the top) or otherwise guarantee the rule precedes any drop. Additionally there is no inverse cleanup: these sysctls and this rule are never removed when the node switches to route-aware mode (direct_egress_is_onlink becomes inactive) or when the node IP/mask changes — a stale rule with the old IP is left behind while a new one is appended each time the config changes.

Comment thread Cubelet/network/runtime/controller.go Outdated
if err := systemnet.CleanupCubeRouter(cubeSNATPortMin()); err != nil {
return nil, err
}
if err := systemnet.ConfigureCubeDevHostRouting(cubeDev, device); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Breaking API change + mode-switch asymmetry. (1) NodeIPMask becomes a hard requirement for cubevs.Init: rewriteConstants fails with invalid IPv4 mask length: 0 whenever it is nil and the mvmtap object (which Init always loads) contains from_cube. Params is an exported struct, so any out-of-repo caller that constructs cubevs.Params without populating the new field now fails at load time. (2) This branch is asymmetric with the route-aware path: when CubeRouterEnable is later set on a node that previously ran direct egress, CleanupCubeRouter removes the router rules, but nothing undoes ConfigureCubeDevHostRouting — cube-dev keeps accept_local=1, rp_filter=2, forwarding=1, and the FORWARD rule indefinitely. A CleanupCubeDevHostRouting counterpart would make the two modes symmetric.

@chenhengqi

Copy link
Copy Markdown
Collaborator

Redirect traffic to cube-dev looks wierd. Have you tired bpf_fib_lookup()?

@jay3cx

jay3cx commented Aug 11, 2026

Copy link
Copy Markdown
Author

Redirect traffic to cube-dev looks wierd. Have you tired bpf_fib_lookup()?

I looked into bpf_fib_lookup(), but on Linux 5.4, which CubeVS supports, it returns NO_NEIGH without triggering ARP when the neighbor is not cached. Since bpf_redirect_neigh() is unavailable there, we still need the host-stack fallback through cube-dev.

@chenhengqi

Copy link
Copy Markdown
Collaborator

How about:

  • Add a new map storing ip=>mac
  • Add a perf event array for notification, if the mac is not found, notify user space to perform an ARP

@jay3cx

jay3cx commented Aug 11, 2026

Copy link
Copy Markdown
Author

How about:

  • Add a new map storing ip=>mac
  • Add a perf event array for notification, if the mac is not found, notify user space to perform an ARP

I gave this a quick try. It works once the map is populated, but the packet that triggers the miss gets dropped before userspace finishes ARP. The first ping/UDP packet was lost, and TCP connected after one retry (~1s). Is that acceptable?

@chenhengqi

Copy link
Copy Markdown
Collaborator

Given we don't have MAC in place, I think the lost is acceptable. A second thought: we can simply turn the lost packet to a ARP packet.

Comment thread CubeNet/cubevs/util.go Outdated
if len(mask) != net.IPv4len {
return 0, fmt.Errorf("invalid IPv4 mask length: %d", len(mask))
}
if _, bits := mask.Size(); bits != 32 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ipMaskToUint32 only checks the mask length via mask.Size().bits, which is always 32 for any 4-byte mask — contiguous or not. net.IPMask{255, 0, 255, 0} yields Size() == (8, 32), so this returns (0x00ff00ff, nil) instead of an error. Consequently the new TestIPMaskToUint32RejectsNonIPv4Mask (util_test.go) fails on its "non-contiguous IPv4 mask" case, so go test ./... in CubeNet/cubevs fails as submitted. Either validate contiguity (e.g. compare mask against net.CIDRMask(ones, 32)) or drop/relax that assertion.

Comment thread CubeNet/cubevs/neighbor_linux.go Outdated
if params.EgressRedirectFlags != 0 {
return nil
}
directNeighborResolverOnce.Do(func() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The resolver is gated by a package-level sync.Once whose error is cached forever, and both readEvents and readARPReplies return permanently on the first non-ErrClosed/non-EINTR error. If CubeVS is ever re-initialized (Init re-run), a new BPF load unlinks and re-creates direct_neigh/direct_neigh_ev, but the old goroutines keep reading the orphaned map instances and the newly created perf-event array has no reader — on-link resolution silently stops with no log. Worth tying the resolver to the same lifecycle as the rest of CubeVS (tear down on re-init, restart on transient failure) or at least documenting that Init is single-use.

Comment thread CubeNet/src/mvmtap.bpf.c
* ARP header. A delayed NIC checksum write therefore lands in padding rather
* than corrupting ar_tip. change_tail also clears any GSO state.
*/
err = bpf_skb_change_tail(skb, DIRECT_ARP_PRESERVED_LEN, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The existing tcp_reply_reset guards bpf_skb_change_tail with if (skb->gso_segs) return TC_ACT_SHOT; because the helper "may fail on GSO skbs or leave segmentation state inconsistent" (see around line 265 in the base file). This new ARP-conversion path calls bpf_skb_change_tail + bpf_skb_change_head unconditionally. The stated validation used isolated network namespaces (veth pairs), which do not exercise GSO or CHECKSUM_PARTIAL offload that a real VM TAP device produces; the CHECKSUM_PARTIAL padding scheme in the comment also relies on skb->csum_start tracking the preserved bytes across bpf_skb_change_head, which is subtle. Please confirm this path is safe for GSO/checksum-offloaded TAP traffic, or add the same skb->gso_segs guard.

@jay3cx
jay3cx force-pushed the fix/issue-1209-direct-egress-onlink branch 2 times, most recently from e798eb3 to f149ddb Compare August 11, 2026 15:30
Comment thread CubeNet/src/map.h
* value: destination MAC address; all zeroes means resolution is pending
*/
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Direct-neighbor entries have no TTL and are only refreshed when a future ARP packet from the peer hits learn_direct_neighbor (BPF_EXIST). The kernel's own neighbor table revalidates stale entries (NUD_STALE → probe, gc_stale_time); this LRU only evicts under pressure, which won't happen on a typical /24 (≤254 on-link hosts vs 8192 entries). If a peer changes its MAC without sending a gratuitous ARP (silent reboot, DHCP lease change, VM recreate) and traffic is mostly one-directional, redirect_egress keeps using the stale MAC indefinitely. Consider storing a timestamp in the value and re-ARPing after an idle threshold (or periodically clearing entries).

Comment thread CubeNet/src/mvmtap.bpf.c
static __always_inline bool direct_egress_is_onlink(__u32 daddr)
{
return egress_redirect_flags == 0 &&
(daddr & nodenic_netmask) == (nodenic_ip & nodenic_netmask);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The pure prefix match also classifies the subnet's network and directed-broadcast addresses as on-link (e.g. for a /24 node, 10.2.3.0 and 10.2.3.255). A sandbox sending a UDP directed broadcast will have its packet consumed by direct_egress_arp_request (it becomes an ARP request for the broadcast address, which no host answers) instead of being delivered to the subnet. Consider excluding the network and broadcast addresses from the on-link set (e.g. daddr != nodenic_ip & nodenic_netmask and daddr != (nodenic_ip & nodenic_netmask) | ~nodenic_netmask).

Comment thread CubeNet/src/mvmtap.bpf.c Outdated
* this path. Until an ARP reply fills it, each packet retries the request.
*/
if (!neighbor)
bpf_map_update_elem(&direct_neigh, &daddr, &pending, BPF_NOEXIST);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every packet to an unresolved on-link neighbor is converted into a fresh ARP request and the original data packet is discarded (the pending entry provides no throttling/backoff). Two consequences worth addressing or documenting:

  1. Unlike the kernel (which queues the first packet in the neighbor table and delivers it after ARP resolution), the first UDP datagram to a cold on-link neighbor is lost — the sender must retransmit.
  2. A sandbox flooding a dead on-link IP emits one broadcast ARP per packet out the node NIC, with no rate limiting (kernel ARP probes are rate-limited via neigh_interval).

Consider storing a timestamp in the pending entry and only re-ARPing after a minimum interval.

@jay3cx jay3cx changed the title fix(cubenet): route direct on-link egress via host fix(cubenet): resolve direct on-link neighbors via ARP Aug 11, 2026
Comment thread CubeNet/src/mvmtap.bpf.c
* ARP header. A delayed NIC checksum write therefore lands in padding rather
* than corrupting ar_tip. change_tail also clears any GSO state.
*/
err = bpf_skb_change_tail(skb, DIRECT_ARP_PRESERVED_LEN, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This bpf_skb_change_tail(skb, 96, 0) only succeeds for packets ≤ ~110 bytes. The kernel guard in net/core/filter.c is if (new_len < len - skb_network_offset(skb)) return -EINVAL; — for a TC packet skb_network_offset is 14 (Ethernet), so shrinking any packet longer than 110 bytes to 96 returns -EINVAL and the function returns TC_ACT_SHOT without emitting the ARP request.

Because redirect_egress() has already created/refreshed the pending entry, every subsequent packet to that destination is either rate-limited (next_probe_at_ns > now) or hits the same failed conversion — so an on-link destination whose triggering packet is > 110 bytes (any UDP datagram with payload > 68 bytes, a large ICMP echo, etc.) stays unresolved indefinitely and the flow blackholes. The PR's validation only exercised 98-byte ICMP pings, which sit just under the threshold, so this wasn't caught.

Note the same file already treats bpf_skb_change_tail as unsafe on large/GSO skbs: tcp_reply_reset guards with if (skb->gso_segs) return TC_ACT_SHOT; (line ~269) before calling it. This path has no equivalent guard, and the comment's claim that "change_tail also clears any GSO state" doesn't hold for the shrink case (skb_trim does not clear gso_segs/gso_size) — with TAP offloads TUN_F_TSO4/6 enabled, GSO skbs can reach from_cube.

Consider building the ARP frame with bpf_skb_store_bytes at the front of the (already large enough) packet without shrinking, or explicitly bounding the trigger-packet size before the conversion.

Comment thread CubeNet/src/mvmtap.bpf.c Outdated
neighbor_mac = (union macaddr *)neighbor->addr;
set_mac_pair(l2, nodenic_macaddr_p1, nodenic_macaddr_p2,
neighbor_mac->p1, neighbor_mac->p2);
return bpf_redirect(dst_ifindex, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Learned entries are never re-validated. next_probe_at_ns is only maintained on the pending (zero-MAC) path, and learn_direct_neighbor writes learned entries with next_probe_at_ns = 0. Once a MAC is cached, this resolved branch is taken forever — there is no TTL and no re-ARP. If an on-link peer changes its MAC without a gratuitous ARP (NIC replacement, VM live-migration on the same L2, IP reassignment), traffic to it blackholes until LRU eviction (only under map pressure) or CubeVS restart.

The kernel's own neighbor cache re-resolves after NUD_STALE; this cache has no equivalent. Suggest using next_probe_at_ns to schedule periodic re-probing of resolved entries (e.g. after a few minutes of inactivity), or at least documenting the staleness trade-off.

Comment thread CubeNet/src/mvmtap.bpf.c
struct ethhdr *l2, __u32 daddr)
{
struct direct_neighbor pending = {};
struct direct_neighbor *neighbor;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Revalidation consumes a data packet every DIRECT_NEIGH_REVALIDATE_INTERVAL_NS per destination: once the deadline passes, the first packet to a learned neighbor is turned into an ARP probe and dropped. In the steady state this silently drops one UDP datagram per destination every 5 minutes (and, while the peer is unresponsive to ARP, repeats roughly once per second). TCP tolerates this via retransmission, but on-link UDP flows will see periodic loss that the PR description does not mention. If the goal is only to revalidate a possibly-changed MAC, consider forwarding the packet with the cached MAC while issuing the probe in the background.

Comment thread CubeNet/src/nodenic.bpf.c
eth_src = (union macaddr *)packet->eth.h_source;
arp_src = (union macaddr *)packet->arp.ar_sha;
/* Only trust a nonzero unicast sender MAC that matches the Ethernet header. */
if (eth_src->p1 != arp_src->p1 || eth_src->p2 != arp_src->p2 ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

learn_direct_neighbor accepts any valid unicast ARP reply whose ar_sip matches an existing direct_neigh key, with no check that the reply was solicited — e.g., ar_tip == nodenic_ip. Any host on the L2 segment can therefore populate the cache for a pending IP with its own MAC, and since this program runs on node NIC ingress it sees all LAN ARP traffic. Given the trusted-LAN model this may be acceptable, but adding packet->arp.ar_tip == nodenic_ip would make the learn strictly reply-driven and close the unsolicited/gratuitous-reply window.

Comment thread CubeNet/cubevs/util.go

// ipMaskToUint32 converts an IPv4 mask to the same byte layout as ipToUint32.
func ipMaskToUint32(mask net.IPMask) uint32 {
return uint32(mask[0]) | uint32(mask[1])<<8 | uint32(mask[2])<<16 | uint32(mask[3])<<24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ipMaskToUint32 indexes mask[0..3] with no length check, unlike ipToUint32 which handles 16-byte net.IP. Params.NodeIPMask is a net.IPMask; a nil or non-4-byte mask (an external caller of the cubevs package, or a device without an IPv4 mask) will panic inside Init() because rewriteConstants calls this unconditionally. The single in-repo caller always supplies a 4-byte IPv4 mask, but a guard (or an explicit length contract) would make this robust.

@chenhengqi

Copy link
Copy Markdown
Collaborator

Thanks. Will take another round of review tomorrow.

Comment thread CubeNet/src/mvmtap.bpf.c
}

now = bpf_ktime_get_ns();
neighbor = bpf_map_lookup_elem(&direct_neigh, &daddr);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Before doing a map lookup, can we try bpf_fib_lookup first?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

fib_lookup first, then ARP if it misses.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done

Comment thread CubeNet/src/mvmtap.bpf.c Outdated
__u16 snat_id;
__u64 flags;
__u64 now;
int mac_result;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sort variables in reverse xmas tree order.

Comment thread CubeNet/src/mvmtap.bpf.c Outdated
packet.arp.ar_sip = nodenic_ip;
packet.arp.ar_tip = daddr;

/* Leave padding after the ARP header so a delayed CHECKSUM_PARTIAL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It's hard to understand what's going on here. Need more comments.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

More comments have been added.

@jay3cx
jay3cx force-pushed the fix/issue-1209-direct-egress-onlink branch from d9d6953 to 2ec756e Compare August 14, 2026 07:35
Comment thread CubeNet/cubevs/miscs.go
@@ -228,10 +229,6 @@ func attachTCFilter(progName string, ifindex uint32, direction TCDirection) erro

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR description says "Clear the direct-neighbor map when CubeVS starts to avoid reusing stale entries", but this diff doesn't do that — and it removes the only two pin-cleanups that existed.

The new direct_neigh map in map.h is pinned with LIBBPF_PIN_BY_NAME, and loadObject opens a collection with Maps.PinPath set. cilium/ebpf reuses an already-pinned map instead of creating a fresh one, so its contents (MAC + next_probe_at_ns deadlines) survive a CubeVS restart. A stale entry whose next_probe_at_ns is still in the future will be used directly (goto set_neighbor in prepare_egress_l2), so a neighbor whose MAC changed while CubeVS was down blackholes traffic for up to DIRECT_NEIGH_REVALIDATE_INTERVAL_NS (5 min) — exactly the failure mode the PR says it prevents.

To clear the map on start, Init needs something like _ = os.Remove(pinPath(MapNameDirectNeigh)). As written, this is missing.

Separately, removing the os.Remove for tungrp_to_tuns and dns_query_track is unrelated to this feature and unexplained. dns_query_track cleanup existed on purpose ("runtime pending-query state, not persisted policy"); now stale pending-DNS entries survive restarts (low impact given the 10 s TTL, but the change isn't mentioned in the PR body).

Comment thread CubeNet/src/mvmtap.bpf.c
return EGRESS_MAC_DROP;
}

if (direct_egress_arp_request(skb, daddr))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Revalidation consumes a live data packet even when the cached MAC is still valid.

When the entry has expired (next_probe_at_ns <= now) but addr is a valid, non-zero MAC, control falls through to direct_egress_arp_request() and the current data packet is converted into an ARP probe (EGRESS_MAC_PROBE → redirect). The packet's payload is discarded, so every DIRECT_NEIGH_REVALIDATE_INTERVAL_NS (5 min) per on-link destination, one data packet is silently dropped: TCP pays a retransmission, UDP loses the datagram.

This is also fragile for GSO/large packets: this codebase already notes in tcp_reply_reset() that bpf_skb_change_tail() "may fail on GSO skbs". If the first packet after expiry is a large GSO segment, direct_egress_arp_request fails, the packet is dropped, next_probe_at_ns was already set to now + 1 s, and a sustained large-packet flow keeps hitting the same failed probe every second until a small packet slips through — effectively stalling the flow for up to 5 minutes.

Consider rewriting the current packet with the cached MAC (forward it now) and setting the deadline so a later packet triggers the probe, rather than sacrificing the triggering packet.

Comment thread CubeNet/src/mvmtap.bpf.c

/* Touch only the deadline so a concurrent learn cannot lose its MAC. */
retry_at = now + DIRECT_NEIGH_PROBE_INTERVAL_NS;
neighbor->next_probe_at_ns = retry_at;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unlocked in-place write to the map value races with learn_direct_neighbor's whole-value update on another CPU.

neighbor->next_probe_at_ns = retry_at; writes directly into the map value through the lookup pointer (no per-entry lock), while learn_direct_neighbor concurrently overwrites the whole struct direct_neighbor via bpf_map_update_elem(..., BPF_EXIST) on another CPU. Two consequences:

  1. Torn MAC exposure: learn_direct_neighbor stores mac->p1 and mac->p2 as two separate stores. A concurrent reader reaching set_neighbor (lines 261-263) can observe p1 from the new MAC and p2 from the old one, and emit frames to a bogus MAC. Because the learned deadline (now + REVALIDATE_INTERVAL) stays in the future, that corrupted entry is used for up to 5 minutes until the next probe.

  2. Deadline clobber: if this write lands after a concurrent learn, the learned next_probe_at_ns (now + 5 min) is overwritten back down to now + 1 s, so the entry re-enters the probe path a second later and a spurious ARP probe consumes another packet.

The re-lookup after the write (intended to detect the concurrent learn) narrows but does not close this window. Consider making the MAC + deadline a single 64-bit-aligned store, or guarding the value with bpf_spin_lock.

Comment thread CubeNet/src/nodenic.bpf.c
packet->arp.ar_pro != bpf_htons(ETH_P_IP) ||
packet->arp.ar_hln != ETH_ALEN ||
packet->arp.ar_pln != sizeof(__be32) ||
packet->arp.ar_op != bpf_htons(ARPOP_REPLY))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "peer announces a MAC change" refresh in the comment above only works for ARP replies, not gratuitous ARP.

This check requires ar_op == ARPOP_REPLY, but a MAC change is normally announced with a gratuitous ARP request (ar_op == ARPOP_REQUEST, ar_tip = own IP). Such packets are silently ignored here, so the "refresh an existing entry when the peer announces a MAC change" behavior described in the comment (lines 20-22) is not actually implemented — the only refresh path is when the peer answers one of our probes. It still eventually catches up via DIRECT_NEIGH_REVALIDATE_INTERVAL_NS, so impact is limited, but the comment overstates the behavior.

Also, the validation accepts an unsolicited ARP reply from any sender whose IP currently has a pending/known entry, without checking that the reply targets our node (ar_tip == nodenic_ip / ar_tha == node MAC). Any on-link host can therefore refresh an existing entry's MAC on demand. That is equivalent to ordinary ARP spoofing on the same segment, but if stricter validation is desired, requiring ar_tip == nodenic_ip would drop unsolicited replies that are not responses to our probes.

@jay3cx
jay3cx force-pushed the fix/issue-1209-direct-egress-onlink branch from 2ec756e to d2833e4 Compare August 14, 2026 07:52
Use bpf_fib_lookup on the node NIC first. If the kernel already has a
neighbor, reuse that MAC; otherwise keep the existing map and ARP path.

Signed-off-by: jay3cx <137191587@qq.com>
@jay3cx
jay3cx force-pushed the fix/issue-1209-direct-egress-onlink branch from d2833e4 to 8afbd05 Compare August 14, 2026 07:54
Comment thread CubeNet/src/mvmtap.bpf.c
* change_tail(96) keeps that slot. change_head(32) moves it past ARP.
* Then store ARP and zero the rest (broadcast).
*/
err = bpf_skb_change_tail(skb, DIRECT_ARP_PRESERVED_LEN, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bpf_skb_change_tail() on a GSO skb can leave segmentation state inconsistent. tcp_reply_reset() in this same file explicitly guards if (skb->gso_segs) return TC_ACT_SHOT; before its bpf_skb_change_tail() call. The cold-miss / revalidation path here can be triggered by a large guest TCP/UDP segment (virtio-net GSO delivers such packets as single skbs with gso_size/gso_segs set), which would be truncated to 96 bytes with GSO metadata still intact — risking a malformed ARP frame on transmit. Recommend dropping when skb->gso_segs != 0 (as tcp_reply_reset does) or clearing GSO state before the rewrite.

Comment thread CubeNet/src/mvmtap.bpf.c

/* Touch only the deadline so a concurrent learn cannot lose its MAC. */
retry_at = now + DIRECT_NEIGH_PROBE_INTERVAL_NS;
neighbor->next_probe_at_ns = retry_at;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Revalidation consumes the triggering data packet: when a learned entry's next_probe_at_ns deadline passes, this packet is converted into an ARP request instead of being forwarded with the already-known MAC. With DIRECT_NEIGH_REVALIDATE_INTERVAL_NS = 5 min, every on-link destination silently drops one packet every 5 minutes; a persistently unreachable peer costs one packet per second. For TCP this surfaces as an RTO retransmission, but for UDP/ICMP it is silent loss. The PR documents this tradeoff, but it is a user-visible behavior change — consider forwarding the current packet with the known MAC and probing out-of-band (e.g., a lightweight standalone probe) instead.

Comment thread CubeNet/cubevs/miscs.go
@@ -228,10 +229,6 @@ func attachTCFilter(progName string, ifindex uint32, direction TCDirection) erro

// Init should be called once before invoking any other CubeVS APIs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This removal is unrelated to the ARP feature and changes reload semantics. The deleted comment states dns_query_track is "runtime pending-query state, not persisted policy" — i.e., deliberately cleared on each Init. With the cleanup gone, stale DNS pending-query entries survive CubeVS restarts because the pinned map is reused from bpffs. If the goal was only to let the new direct_neigh cache survive reloads, these removals should be reverted or explicitly called out in the PR description.

Comment thread CubeNet/src/mvmtap.bpf.c
/* Primary IPv4 prefix of the node NIC. Direct mode only. */
static __always_inline bool direct_egress_is_onlink(__u32 daddr)
{
return egress_redirect_flags == 0 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

direct_egress_is_onlink() also matches subnet-directed broadcasts (e.g., 10.0.0.255 for a /24 node prefix). In direct mode the first broadcast packet is rewritten into an ARP request for an address nobody answers, and subsequent broadcasts are dropped during the 1 s probe window — a regression from the previous gateway-MAC behavior. If any workload relies on directed broadcast in direct mode, consider excluding broadcast/multicast addresses from the on-link ARP path (e.g., reject when daddr is the subnet or network address).

@chenhengqi

Copy link
Copy Markdown
Collaborator

@jay3cx I think we can place the MAC address preparation work right after:

if (l3->daddr == nodenic_ip) {
/* This branch bypasses do_*_nat() and therefore the policy
* check inside create_nat_session(). Enforce policy inline.
* TCP callers get an RST to match the guest-visible behavior
* of the do_tcp_nat() path; UDP/ICMP silently drop.
*/
if (!session_policy_allowed(ifindex, daddr)) {
if (proto == IPPROTO_TCP)
return tcp_reply_reset(skb, ifindex);
return TC_ACT_SHOT;
}
return bpf_redirect(cubegw0_ifindex, BPF_F_INGRESS);
}

So that we don't have to touch protocol-specific code.

Move neighbor lookup and ARP conversion out of the per-protocol NAT
helpers so from_cube rewrites L2 in one place before those helpers run.

Signed-off-by: jay3cx <137191587@qq.com>
@jay3cx
jay3cx force-pushed the fix/issue-1209-direct-egress-onlink branch from 04d6e3d to a5538f8 Compare August 17, 2026 10:57
Comment thread CubeNet/src/mvmtap.bpf.c
return EGRESS_MAC_READY;
}

err = bpf_fib_lookup(skb, &fib, sizeof(fib),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR targets Linux 5.4, but BPF_FIB_LOOKUP_DIRECT was only introduced in a later kernel (5.10). On 5.4 the helper will almost certainly reject the unknown flag (-EINVAL), so this fast path silently never fires and every on-link packet falls through to the neighbor-map/ARP path. The fallback still works (and was validated on 5.4), so this is not a functional break — but worth verifying: if 5.4 doesn't support the flag, either gate it on the kernel version or drop the direct flag on old kernels, and note that BPF_FIB_LOOKUP_OUTPUT can also trigger the kernel's own neighbor resolution (duplicate ARP requests on the segment).

Comment thread CubeNet/src/mvmtap.bpf.c
return bpf_redirect(cubegw0_ifindex, BPF_F_INGRESS);
}

mac_result = prepare_egress_l2(skb, l2, daddr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Calling prepare_egress_l2() here moves ARP resolution before the NAT/policy checks that live in do_tcp_nat/do_udp_nat_inline/do_icmp_nat (policy deny + RST-on-deny happen inside create_nat_session). Consequences:

  • A policy-denied on-link destination now still emits an ARP request on the wire (the base code never touched the wire for denied flows).
  • The first packet of every flow to a new/expired on-link neighbor is consumed as an ARP probe — including the SYN that would previously have drawn an immediate RST-on-deny; the RST only appears ~1s later once the neighbor is learned.

Confirm this ordering is acceptable for the policy model; if not, the neighbor lookup would need to run after session creation.

Comment thread CubeNet/src/nodenic.bpf.c
packet->arp.ar_pro != bpf_htons(ETH_P_IP) ||
packet->arp.ar_hln != ETH_ALEN ||
packet->arp.ar_pln != sizeof(__be32) ||
packet->arp.ar_op != bpf_htons(ARPOP_REPLY))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This accepts any ARP reply whose sender MAC matches the Ethernet source, without checking that the reply's target IP (ar_tip) is the node's own IP (nodenic_ip). The BPF_EXIST guard limits learning to IPs already probed, but within that set a spoofed/unsolicited self-consistent reply (e.g. from an on-link attacker who observed the probe) populates the cache. Consider also requiring packet->arp.ar_tip == nodenic_ip so only replies addressed to the node are learned.

Comment thread CubeNet/cubevs/miscs.go
@@ -228,10 +229,6 @@ func attachTCFilter(progName string, ifindex uint32, direction TCDirection) erro

// Init should be called once before invoking any other CubeVS APIs.
func Init(params Params) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This cleanup removal looks unrelated to the on-link-neighbor feature and changes restart semantics: a stale pinned dns_query_track map is now reused across CubeVS restarts instead of being cleared (the old comment explicitly said it's runtime pending-query state, not persisted policy), and a stale tungrp_to_tuns pin file lingers on bpffs. Probably benign (entries are TTL'd), but worth a comment explaining why, or splitting into a separate change.

Comment thread CubeNet/src/mvmtap.bpf.c
static __always_inline bool direct_egress_is_onlink(__u32 daddr)
{
return egress_redirect_flags == 0 &&
(daddr & nodenic_netmask) == (nodenic_ip & nodenic_netmask);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This prefix match also classifies the subnet-directed broadcast address (e.g. x.x.x.255 on a /24) as on-link. If bpf_fib_lookup doesn't resolve it to the all-ones MAC, the packet gets converted into an ARP request for the broadcast address (which no host answers) and consumed, changing existing broadcast semantics. Consider excluding broadcast/multicast destinations (INADDR_BROADCAST, ipv4_is_multicast) from the on-link/ARP path.

@jay3cx

jay3cx commented Aug 18, 2026

Copy link
Copy Markdown
Author

@jay3cx I think we can place the MAC address preparation work right after:

if (l3->daddr == nodenic_ip) {
/* This branch bypasses do_*_nat() and therefore the policy
* check inside create_nat_session(). Enforce policy inline.
* TCP callers get an RST to match the guest-visible behavior
* of the do_tcp_nat() path; UDP/ICMP silently drop.
*/
if (!session_policy_allowed(ifindex, daddr)) {
if (proto == IPPROTO_TCP)
return tcp_reply_reset(skb, ifindex);
return TC_ACT_SHOT;
}
return bpf_redirect(cubegw0_ifindex, BPF_F_INGRESS);
}

So that we don't have to touch protocol-specific code.

@chenhengqi Moved it there.

Comment thread CubeNet/src/mvmtap.bpf.c
Comment on lines +242 to +248
neighbor = bpf_map_lookup_elem(&direct_neigh, &daddr);
if (!neighbor)
return EGRESS_MAC_DROP;
/* Later deadline: another CPU already refreshed this entry. */
if (!direct_neighbor_is_zero(neighbor) &&
neighbor->next_probe_at_ns > retry_at)
goto set_neighbor;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can simply always do an ARP probe here, right? I am not sure how frequent this concurrent learning will happen.

Comment thread CubeNet/src/mvmtap.bpf.c
2 * DIRECT_ARP_ZERO_CHUNK_LEN, 0);
}

static __always_inline long direct_egress_arp_request(struct __sk_buff *skb, __u32 daddr)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

See

static __always_inline int tcp_reply_reset(struct __sk_buff *skb, __u32 ifindex)

I still have doubt in the implementation here. Please explain the purpose of bpf_skb_change_tail/bpf_skb_change_head/direct_egress_clear_arp_padding here. I haven't consult an AI agent yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug Report] Direct mode unconditionally sets destination MAC to gateway MAC

2 participants